mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
1e69c90981
# Physical iOS device support Try it now (cabled iPhone, unlocked, Developer Mode on): ```bash argent run list-devices # copy the 25-char UDID argent run launch-app --udid <UDID> --bundleId com.apple.Preferences ``` First launch builds and signs the on-device runner: 3 to 5 minutes, keep the phone unlocked. Every later start: seconds, from cache. No signing setup needed if an Apple ID is signed into Xcode; the team is detected from the keychain. ## What this adds - Physical iPhone support, on by default: a cabled iPhone simply appears in `list-devices`. iPhone-only: physical iPads are skipped at discovery. - 15 tools ported to hardware. Flows replay on device. - Zero-config signing from the keychain; `ARGENT_IOS_TEAM_ID` is an override, nothing is persisted. - Self-contained npm install: the runner's Swift sources ship in the package. New package `@argent/ios-device-runner` (an XCUITest bundle hosting an HTTP command server on the device), new tool-server backend under `src/utils/ios-device/`. - The one always-on cost: `list-devices` on macOS now runs physical discovery in parallel with the other platform probes, under the existing 8s devicectl timeout; off-macOS it early-returns. ## Suggested review order Rough estimate: half a day for the full pass. 1. `packages/ios-device-runner/PROTOCOL.md`, then `README.md` (wire contract and reliability model, ~15 min) 2. `packages/tool-server/src/utils/ios-device/` (devicectl, usbmux, team-detect, runner-build, runner-commands: the core) 3. `packages/tool-server/src/blueprints/ios-device-runner.ts` (service lifecycle, crash recovery) 4. Per-tool device arms and the capability gates (`ios-physical-device-classification.test.ts` is the ratchet) 5. Docs page, the two new skills, the Swift runner sources ## Requirements 1. macOS host with full Xcode (iOS platform installed); uses `xcrun devicectl` and `xcodebuild`. 2. An Apple ID signed into Xcode; automatic signing mints the runner's provisioning profile. 3. USB cable only. Wi-Fi is deliberately unsupported: the transport is usbmux, and a Wi-Fi-only device lists as `paired`, not `connected`. 4. iPhone paired with and trusting the Mac, Developer Mode on, A12/2018 or newer (modern 8+16-hex UDID, the CoreDevice floor). Legacy 40-hex UDIDs are not classified as iOS. 5. Signing resolves as: `ARGENT_IOS_TEAM_ID` in the tool-server's environment (explicit override, CI) -> keychain detection (newest identity first, memoized per server run, never persisted). One certificate: nothing to do. Several teams: newest wins; the first tool result names the pick, the runner-ups, and the override. No certificate: the error walks through Xcode > Settings > Accounts and Manage Certificates > + > Apple Development, then retry; it never names the env var, since a team id alone cannot sign. Bundle ids derive per team (`com.argent.runner.t<teamid>`). `ARGENT_IOS_RUNNER_PROJECT` overrides the project path for unusual layouts. Build artifacts cache under `~/.argent/ios-device-runner`, keyed by runner sources, Xcode/SDK version, and signing. A team with no registered devices self-heals: the failed generic build retries against the connected phone, which registers it. ## How it works 1. `xcrun devicectl` (CoreDevice) is the control plane: discovery, readiness, app lifecycle. Readiness gates on the wired transport, so an unplugged phone is refused before any build starts. 2. A signed XCUITest runner is built lazily with the resolved team, started via `xcodebuild test-without-building`, and parks as an HTTP command server for 24h. On an npm install the Xcode project is the copy shipped inside the package. 3. The listener binds to the device's loopback interface; the host reaches it over usbmuxd with a dependency-free `node:net` client. 4. Mutating commands are journaled by `commandId`; a lost reply is resolved by asking `status` for the journaled fate, never by replay (at-most-once delivery). 5. Routing hangs off `resolveDevice` UDID-shape classification plus a per-tool capability allowlist. CLI and MCP layers are untouched; outside tool-server, docs, and the runner package the diff is failure-code constants (registry), two agent skills plus rule updates (skills), the pack step (packages/argent), and the e2e smoke script. ## Supported tools | Tool | Status | Notes | | ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | launch-app | Supported | Plain `devicectl` launch. System UI (SpringBoard/Spotlight) registers a session only; no launch attempt. Carries a note when signing is not resolvable yet. | | restart-app | Supported | System UI bundle ids rejected before any device contact. | | reinstall-app | Supported | Same system UI pre-flight. | | screenshot | Supported | Served through the runner; device-wide, needs no registered app. (No host-side devicectl screenshot exists on any toolchain; that dead route was removed.) | | describe | Supported | AX tree snapshot via the runner. Snapshots the session app's process, so another process's dialogs are not in the tree. A backgrounded session app fails with an actionable error instead of being silently re-fronted. Focus polling is off for this source; typing uses a fixed settle. | | keyboard | Supported | Types into the focused element; typing with nothing focused is refused with "tap the text field first". `enter` and `backspace` are the named keys (both exist as software-keyboard characters; there is no per-keycode HID surface on hardware). Whitespace-only keys rejected, not silently "typed". | | gesture-tap | Supported | `clickCount > 1` dispatches as one native multi-tap gesture, not separate taps. | | gesture-swipe | Supported | Single-finger drag between two points. | | gesture-custom | Supported | Two shapes only: press-hold and straight single-finger drag. Second finger or `Move` waypoints rejected with authoring guidance, before any runner starts. | | button | Supported | `home`, `volumeUp`, `volumeDown`, `actionButton` (volume buttons are simulator-unavailable in the SDK, not device-unavailable). `power`/`appSwitch` have no public XCUIDevice API and reject before device contact; a button the model lacks gets a typed refusal via `hasHardwareButton`. | | await-ui-element | Supported | Polls the same runner snapshot describe returns; waits and taps see identical frames. Degrades to `cause: "unreadable"` with a note on a backgrounded app, never a raw throw. | | await-screen-idle | Supported | Same describe path; resolves not-settled instead of throwing, so a follow-up screenshot still runs. | | run-sequence | Supported | Outer gate only; each step pre-flights its own capability. | | open-url | Supported | devicectl launches the receiving app with the URL as its launch payload. http(s) defaults to Safari; any other scheme needs the new optional `bundleId` param (hardware cannot resolve a scheme's handler system-wide). | | screenshot-diff | Supported | Live captures through the runner (full-resolution, device-wide, no registered app needed); the diff itself runs on the host. Baselines are per device model/resolution; `rotation` is ignored on hardware. | Flows run through these tools; the device flow tree gets the same `subtreeText` hoisting as every other platform, so container-scoped `text` asserts work. ## Not supported (400 at the capability gate) 1. gesture-pinch, gesture-rotate: no two-finger coordinate API on hardware. Pinch/rotate flow directives also fail upfront with a clear error. 2. screen-recording-start/stop (video): simulator-only capture pipeline. 3. rotate, paste, shake, settings-permissions: simulator-only backends, not ported (paste also hits the iOS 16+ cross-app paste permission prompt). 4. native-devtools, debugger, profiler families: simulator-side instrumentation (DYLD injection does not exist on hardware). 5. Physical iPads: filtered at discovery for now. A test ratchet (`ios-physical-device-classification.test.ts`) keeps `apple.device` declarations in sync with the ported list. ## Core contracts 1. Observation never mutates the screen: a backgrounded session app makes `describe`/`await-*` fail with an error naming the three ways out (screenshot, launch-app, or launch-app com.apple.springboard). Mutating commands do re-front, and their result carries `reactivated: true`. 2. The runner listener is unauthenticated; mitigation is the loopback-only bind. 3. `list-devices` states: `connected` (cabled now, usable) vs `paired` (unreachable, never auto-bound). Wired transport alone decides; a Wi-Fi-migrated CoreDevice tunnel does not count. 4. The multi-team signing pick rides the first affected tool result as a one-shot note (the pre-existing notes channel). The `list-devices` result shape is unchanged from main: devices and avds, nothing else. 5. Rejections are typed 400 validation errors end to end, so a batch flow run skips one failing flow instead of aborting. Also: the runner is a first-class device-owned service (`stop-simulator-server` and `stop-all-simulator-servers` reap it, detached xcodebuild child included; a crashed runner respawns on the next call). A passcode-locked phone refuses launches; the devicectl 10002 hint also covers a pending system prompt on an unlocked phone. Build failures surface xcodebuild's own `error:` lines with signing hints (no registered devices, free-team app-id cap, missing provisioning, the `errSecInternalComponent` partition-list fix). An app XCTest reports as `.unknown` (a swipe-killed foreign app) is refused with launch-app guidance. Status-bar pinning is skipped on hardware; pixel-settle masks the top band. ## Testing - Unit coverage, hardware-independent: UDID classification and capability routing; usbmux framing and transport; devicectl list/hints parsing incl. the silent iPhone-only filter; keychain detection against real certificate fixtures; runner build/cache self-heals (poisoned cache, concurrent checkout, fresh-team retry); crash/journal recovery; the backgrounded-observation and reactivated contracts; keyboard guards incl. the not-focused refusal; system-UI pre-flight; per-tool device arms. - Manual smoke on a real device: `scripts/e2e-ios-physical-device.mjs`. ## Docs and skills 1. `features/physical-ios-devices.mdx`: the user-facing walkthrough (zero-config signing with the sign-in fallback, prerequisites, app-scoped model, troubleshooting). 2. `fundamentals/supported-platforms.mdx`: physical iPhones in the platform matrix (iPads not supported yet). 3. `reference/tools.mdx` and `reference/configuration.mdx`: list-devices states and the `ARGENT_IOS_TEAM_ID` override. 4. Two skills ship with the package via `argent init`: `argent-ios-device-setup` (setup ladder plus a troubleshooting table keyed to real error texts) and `argent-ios-device-interact` (the app-scoped hardware contract, supported subset with alternatives, gesture recipes, pitfalls). The agent rule and sibling skills route to them. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added experimental USB-connected physical iPhone support. * Physical iPhones now support discovery, app lifecycle actions, URL opening, accessibility snapshots, screenshots, taps, swipes, custom gestures, keyboard input, waits, flows, and screenshot comparisons. * Added automatic signing-team detection with optional `ARGENT_IOS_TEAM_ID` configuration. * Added clear handling for paired or unavailable devices and unsupported hardware actions. * **Documentation** * Added setup, troubleshooting, capability, and physical-device usage guidance. * Clarified that physical iPads and several simulator-only tools and gestures remain unsupported. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
681 lines
27 KiB
TypeScript
681 lines
27 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
|
|
import {
|
|
createRunnerClient,
|
|
isRunnerWedgedError,
|
|
RUNNER_COMMAND_TIMEOUT_MS,
|
|
RunnerCommandError,
|
|
waitForRunnerReady,
|
|
type RunnerCommand,
|
|
type RunnerResponseEnvelope,
|
|
type RunnerResponseError,
|
|
} from "../src/utils/ios-device/runner-client";
|
|
import type { SendRunnerCommandOptions } from "../src/utils/ios-device/runner-route";
|
|
import { IosDeviceTransportError } from "../src/utils/ios-device/usbmux-protocol";
|
|
|
|
const UDID = "00008110-000978540290401E";
|
|
const PORT = 8_100;
|
|
|
|
type SentCommand = { body: RunnerCommand; options: SendRunnerCommandOptions };
|
|
|
|
const createFakeSend = (script: Array<unknown | Error>) => {
|
|
const sent: SentCommand[] = [];
|
|
const send = vi.fn(
|
|
async (_udid: string, _port: number, body: unknown, options: SendRunnerCommandOptions) => {
|
|
sent.push({ body: body as RunnerCommand, options });
|
|
const next = script.shift();
|
|
if (next instanceof Error) throw next;
|
|
return next;
|
|
}
|
|
);
|
|
return { send, sent };
|
|
};
|
|
|
|
const transportError = (): IosDeviceTransportError =>
|
|
new IosDeviceTransportError("http", "socket hang up mid-response", { retryable: true });
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("createRunnerClient", () => {
|
|
it("stamps a fresh argent-prefixed commandId on non-status commands", async () => {
|
|
const { send, sent } = createFakeSend([
|
|
{ ok: true, data: { done: true } } satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 10, y: 20 });
|
|
|
|
expect(result).toEqual({ done: true });
|
|
expect(sent[0]?.body.commandId).toMatch(
|
|
/^argent-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
);
|
|
expect(send).toHaveBeenCalledWith(UDID, PORT, expect.anything(), expect.anything());
|
|
});
|
|
|
|
it("preserves a caller-provided commandId and never stamps status commands", async () => {
|
|
const { send, sent } = createFakeSend([
|
|
{ ok: true, data: {} },
|
|
{ ok: true, data: {} },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
await client.run({ command: "tap", commandId: "argent-known" });
|
|
await client.run({ command: "status", statusCommandId: "argent-known" }, { readOnly: true });
|
|
|
|
expect(sent[0]?.body.commandId).toBe("argent-known");
|
|
expect(sent[1]?.body.commandId).toBeUndefined();
|
|
});
|
|
|
|
it("uses the 45s default timeout and forwards readOnly to the send layer", async () => {
|
|
const { send, sent } = createFakeSend([
|
|
{ ok: true, data: {} },
|
|
{ ok: true, data: {} },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
await client.run({ command: "snapshot" }, { readOnly: true });
|
|
await client.run({ command: "tap" }, { timeoutMs: 1_234 });
|
|
|
|
expect(sent[0]?.options).toEqual({ timeoutMs: RUNNER_COMMAND_TIMEOUT_MS, readOnly: true });
|
|
expect(sent[1]?.options).toEqual({ timeoutMs: 1_234, readOnly: false });
|
|
});
|
|
|
|
it("throws a typed RunnerCommandError for ok:false envelopes", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: false,
|
|
error: { code: "ELEMENT_NOT_FOUND", message: "no such element", hint: "run snapshot" },
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect(error).toBeInstanceOf(RunnerCommandError);
|
|
expect((error as RunnerCommandError).code).toBe("ELEMENT_NOT_FOUND");
|
|
// The hint is folded into the message: agent-facing rendering surfaces
|
|
// only .message, so the guidance must live there.
|
|
expect((error as RunnerCommandError).message).toBe("no such element. Hint: run snapshot");
|
|
expect((error as RunnerCommandError).hint).toBe("run snapshot");
|
|
});
|
|
|
|
it("does not fold the hint twice when the message already carries it", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: false,
|
|
error: {
|
|
code: "ELEMENT_NOT_FOUND",
|
|
message: "no such element. Hint: run snapshot",
|
|
hint: "run snapshot",
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect((error as RunnerCommandError).message).toBe("no such element. Hint: run snapshot");
|
|
});
|
|
|
|
describe("host hints for a busy or wedged runner", () => {
|
|
const BUSY_HINT =
|
|
"Wait a few seconds and retry; if snapshots keep failing on this screen, use " +
|
|
"screenshot as visual truth and interact by coordinates.";
|
|
const WEDGED_MESSAGE =
|
|
"The runner's main thread has been stuck in abandoned work for 130s and cannot " +
|
|
"recover on its own.";
|
|
const RECYCLE_HINT = `Run stop-simulator-server ${UDID} to recycle the runner, then retry the command.`;
|
|
|
|
it("extends the RUNNER_BUSY hint with the tool that recycles the runner", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: false,
|
|
error: { code: "RUNNER_BUSY", message: "busy", hint: BUSY_HINT },
|
|
} satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
// The runner's own advice stays. What it cannot name is the host tool
|
|
// that ends a session, so that is appended here, where the udid is known.
|
|
const hint = `${BUSY_HINT} If it stays busy, run stop-simulator-server ${UDID} to recycle the runner.`;
|
|
expect((error as RunnerCommandError).hint).toBe(hint);
|
|
expect((error as RunnerCommandError).message).toBe(`busy. Hint: ${hint}`);
|
|
expect(isRunnerWedgedError(error)).toBe(false);
|
|
});
|
|
|
|
it("replaces the RUNNER_WEDGED hint, which only says to restart the session", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: false,
|
|
error: {
|
|
code: "RUNNER_WEDGED",
|
|
message: WEDGED_MESSAGE,
|
|
hint: "Restart the runner session, then retry the command.",
|
|
} satisfies RunnerResponseError,
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect(isRunnerWedgedError(error)).toBe(true);
|
|
expect((error as RunnerCommandError).hint).toBe(RECYCLE_HINT);
|
|
expect((error as RunnerCommandError).message).toBe(`${WEDGED_MESSAGE} Hint: ${RECYCLE_HINT}`);
|
|
});
|
|
|
|
it("maps the hint on a journaled failure the same way", async () => {
|
|
const { send } = createFakeSend([
|
|
transportError(),
|
|
{
|
|
ok: true,
|
|
data: {
|
|
state: "failed",
|
|
errorCode: "RUNNER_WEDGED",
|
|
errorMessage: "stuck",
|
|
errorHint: "Restart the runner session, then retry the command.",
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect(isRunnerWedgedError(error)).toBe(true);
|
|
expect((error as RunnerCommandError).message).toBe(`stuck. Hint: ${RECYCLE_HINT}`);
|
|
});
|
|
});
|
|
|
|
it("stamps every RunnerCommandError with the runner-command failure signal", async () => {
|
|
const { send } = createFakeSend([
|
|
{ ok: false, error: { code: "ELEMENT_NOT_FOUND", message: "no such element" } },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
// Telemetry classification (T44): a runner-reported failure must not fall
|
|
// into the registry's unclassified bucket, and it is the runner's verdict,
|
|
// not devicectl's, so it carries the runner-command code. The wire code
|
|
// stays on `.code`.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_COMMAND_FAILED);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_command");
|
|
expect(signal?.error_kind).toBe("unknown");
|
|
expect((error as RunnerCommandError).code).toBe("ELEMENT_NOT_FOUND");
|
|
});
|
|
|
|
it.each([
|
|
["COMMAND_TIMED_OUT", "timeout"],
|
|
["TEXT_INPUT_NOT_FOCUSED", "validation"],
|
|
["APP_BUNDLE_ID_REQUIRED", "validation"],
|
|
["INVALID_REQUEST", "validation"],
|
|
["UNSUPPORTED_OPERATION", "validation"],
|
|
["APP_NOT_AVAILABLE", "unknown"],
|
|
["RUNNER_WEDGED", "unknown"],
|
|
] as const)("classifies the runner code %s as error_kind %s", async (code, kind) => {
|
|
const { send } = createFakeSend([{ ok: false, error: { code, message: "refused" } }]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
// A request the runner refused on its shape or target reads as validation
|
|
// and a watchdog overrun as timeout; every other wire code stays unknown
|
|
// rather than guessing.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_COMMAND_FAILED);
|
|
expect(signal?.error_kind).toBe(kind);
|
|
});
|
|
|
|
describe("reactivated pass-through", () => {
|
|
it("copies a success envelope's reactivated marker onto the returned data object", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: true,
|
|
data: { message: "tapped" },
|
|
reactivated: true,
|
|
} satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
// The runner re-fronted a backgrounded target before the tap: the flag
|
|
// rides the data object so the tool layer can tell the agent the
|
|
// foreground screen changed underneath the command.
|
|
expect(result).toEqual({ message: "tapped", reactivated: true });
|
|
});
|
|
|
|
it("returns the data untouched (same reference) when the envelope has no marker", async () => {
|
|
const data = { message: "tapped" };
|
|
const { send } = createFakeSend([{ ok: true, data } satisfies RunnerResponseEnvelope]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
expect(result).toBe(data);
|
|
});
|
|
|
|
it("carries an error envelope's marker onto the RunnerCommandError and its message", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: false,
|
|
error: { code: "COMMAND_FAILED", message: "stale element", hint: "run snapshot" },
|
|
reactivated: true,
|
|
} satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap", x: 1, y: 2 })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
// The re-front happened before the tap, so the foreground screen changed
|
|
// even though the tap did not land; the agent has to hear that on the
|
|
// failure path too, and only .message reaches it.
|
|
expect(error).toBeInstanceOf(RunnerCommandError);
|
|
expect((error as RunnerCommandError).reactivated).toBe(true);
|
|
expect((error as RunnerCommandError).message).toBe(
|
|
"stale element. The app was re-fronted before the command ran, so the foreground " +
|
|
"screen changed. Hint: run snapshot"
|
|
);
|
|
});
|
|
|
|
it("leaves reactivated false and the message untouched on an error envelope without it", async () => {
|
|
const { send } = createFakeSend([
|
|
{ ok: false, error: { code: "COMMAND_FAILED", message: "stale element" } },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap", x: 1, y: 2 })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect((error as RunnerCommandError).reactivated).toBe(false);
|
|
expect((error as RunnerCommandError).message).toBe("stale element");
|
|
});
|
|
|
|
it("resurfaces the marker from a journal-retained response", async () => {
|
|
const { send } = createFakeSend([
|
|
transportError(),
|
|
{
|
|
ok: true,
|
|
data: {
|
|
state: "completed",
|
|
responseJson: JSON.stringify({
|
|
ok: true,
|
|
data: { message: "tapped" },
|
|
reactivated: true,
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
expect(result).toEqual({ message: "tapped", reactivated: true });
|
|
});
|
|
});
|
|
|
|
describe("warning pass-through", () => {
|
|
const SUPPRESSED_NOISE_WARNING =
|
|
"accessibility noise was suppressed during this gesture; " +
|
|
"re-observe the screen to confirm the effect.";
|
|
|
|
it("copies a success envelope's warning onto the returned data object", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: true,
|
|
data: { message: "tapped" },
|
|
warning: SUPPRESSED_NOISE_WARNING,
|
|
} satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
// Success replies have no hint channel, so the runner's advisory rides
|
|
// the data object for the tool layer to surface.
|
|
expect(result).toEqual({ message: "tapped", warning: SUPPRESSED_NOISE_WARNING });
|
|
});
|
|
|
|
it("composes with reactivated: one reply carries both markers", async () => {
|
|
const { send } = createFakeSend([
|
|
{
|
|
ok: true,
|
|
data: { message: "tapped" },
|
|
reactivated: true,
|
|
warning: SUPPRESSED_NOISE_WARNING,
|
|
} satisfies RunnerResponseEnvelope,
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
expect(result).toEqual({
|
|
message: "tapped",
|
|
reactivated: true,
|
|
warning: SUPPRESSED_NOISE_WARNING,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("status recovery after a lost mutating-command response", () => {
|
|
it("returns the retained response when the runner reports the command completed", async () => {
|
|
const { send, sent } = createFakeSend([
|
|
transportError(),
|
|
{
|
|
ok: true,
|
|
data: {
|
|
state: "completed",
|
|
responseJson: JSON.stringify({ ok: true, data: { tapped: true } }),
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const result = await client.run({ command: "tap", x: 1, y: 2 });
|
|
|
|
// The tap happened; replaying it would tap twice. Recovery returns the
|
|
// response the transport lost instead.
|
|
expect(result).toEqual({ tapped: true });
|
|
expect(sent).toHaveLength(2);
|
|
expect(sent[1]?.body).toEqual({
|
|
command: "status",
|
|
statusCommandId: sent[0]?.body.commandId,
|
|
});
|
|
expect(sent[1]?.options).toEqual({ timeoutMs: 3_000, readOnly: true });
|
|
});
|
|
|
|
it("surfaces the runner's own error when the runner reports the command failed", async () => {
|
|
const { send } = createFakeSend([
|
|
transportError(),
|
|
{
|
|
ok: true,
|
|
data: {
|
|
state: "failed",
|
|
errorCode: "ELEMENT_NOT_FOUND",
|
|
errorMessage: "target vanished",
|
|
errorHint: "run snapshot",
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect(error).toBeInstanceOf(RunnerCommandError);
|
|
expect((error as RunnerCommandError).code).toBe("ELEMENT_NOT_FOUND");
|
|
expect((error as RunnerCommandError).message).toBe("target vanished. Hint: run snapshot");
|
|
expect((error as RunnerCommandError).hint).toBe("run snapshot");
|
|
});
|
|
|
|
it("rethrows the transport error when the journal state is unknown", async () => {
|
|
const original = transportError();
|
|
const { send } = createFakeSend([original, { ok: true, data: { state: "started" } }]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBe(original);
|
|
// The rethrown object is stamped IN PLACE (T44); identity above proves
|
|
// the stamp cannot have replaced the error callers compare against. The
|
|
// exchange failed against a runner that was reachable, so this is a
|
|
// transport failure, not a runner that never became ready.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_TRANSPORT_FAILED);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_transport");
|
|
expect(signal?.error_kind).toBe("network");
|
|
});
|
|
|
|
it("rethrows the transport error when the status probe fails the same way", async () => {
|
|
const original = transportError();
|
|
const { send, sent } = createFakeSend([original, transportError()]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
// A second mid-exchange failure says nothing the first did not.
|
|
expect(error).toBe(original);
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"device-unattached",
|
|
{ hint: "Connect the device by cable." },
|
|
"usbmux connect failed. Hint: Connect the device by cable.",
|
|
],
|
|
["runner-not-listening", {}, "usbmux connect failed"],
|
|
] as const)(
|
|
"surfaces the probe's pre-send verdict %s over the bare transport error",
|
|
async (kind, options, message) => {
|
|
const original = transportError();
|
|
const probe = new IosDeviceTransportError(kind, "usbmux connect failed", {
|
|
retryable: false,
|
|
...options,
|
|
});
|
|
const { send, sent } = createFakeSend([original, probe]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
// The cable came out, or the runner stopped listening, between the tap
|
|
// and the probe. That verdict carries the hint the agent needs now; a
|
|
// bare "read ECONNRESET" would leave it guessing until the next call.
|
|
expect(error).toBe(probe);
|
|
expect((error as Error).message).toBe(message);
|
|
expect((error as Error).cause).toBe(original);
|
|
expect(sent).toHaveLength(2);
|
|
// The probe never went through run(), so it is stamped here.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_transport");
|
|
}
|
|
);
|
|
|
|
it("rethrows the transport error when completed but no response was retained", async () => {
|
|
const original = transportError();
|
|
const { send } = createFakeSend([original, { ok: true, data: { state: "completed" } }]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBe(original);
|
|
});
|
|
|
|
it("does not attempt recovery for read-only commands", async () => {
|
|
const original = transportError();
|
|
const { send, sent } = createFakeSend([original]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "snapshot" }, { readOnly: true })
|
|
.catch((caught: unknown) => caught);
|
|
|
|
// The send layer already retried idempotent sends; a status probe
|
|
// could tell us nothing a retry did not.
|
|
expect(error).toBe(original);
|
|
expect(sent).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
["device-unattached", false],
|
|
["runner-not-listening", true],
|
|
] as const)(
|
|
"does not attempt recovery for the pre-send kind %s: one send, original error rethrown",
|
|
async (kind, retryable) => {
|
|
const original = new IosDeviceTransportError(kind, "usbmux connect failed", { retryable });
|
|
const { send, sent } = createFakeSend([original]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap", x: 1, y: 2 })
|
|
.catch((caught: unknown) => caught);
|
|
|
|
// The usbmux connection never opened: the tap cannot have landed, and
|
|
// a status probe would ride the same dead route.
|
|
expect(error).toBe(original);
|
|
expect(sent).toHaveLength(1);
|
|
// Pre-send kinds are user-reachable too, so they carry the stamp; no
|
|
// runner was reached at all, so they keep the not-ready code.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_transport");
|
|
}
|
|
);
|
|
|
|
it("still attempts recovery for a post-send timeout: the command may have run", async () => {
|
|
const original = new IosDeviceTransportError("timeout", "HTTP exchange timed out", {
|
|
retryable: false,
|
|
});
|
|
const { send, sent } = createFakeSend([original, { ok: true, data: { state: "started" } }]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client.run({ command: "tap" }).catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBe(original);
|
|
expect(sent).toHaveLength(2);
|
|
expect(sent[1]?.body.command).toBe("status");
|
|
// A mid-session timeout on a ready runner is a transport failure with
|
|
// the timeout kind, not a runner that never became ready.
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_TRANSPORT_FAILED);
|
|
expect(signal?.error_kind).toBe("timeout");
|
|
});
|
|
|
|
it("surfaces the retained ok:false envelope as the command's real outcome", async () => {
|
|
const { send } = createFakeSend([
|
|
transportError(),
|
|
{
|
|
ok: true,
|
|
data: {
|
|
state: "completed",
|
|
responseJson: JSON.stringify({
|
|
ok: false,
|
|
error: { code: "RUNNER_BUSY", message: "was busy" },
|
|
}),
|
|
},
|
|
},
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const error = await client
|
|
.run({ command: "tap" })
|
|
.catch((caught: unknown) => caught as RunnerCommandError);
|
|
|
|
expect(error).toBeInstanceOf(RunnerCommandError);
|
|
expect((error as RunnerCommandError).code).toBe("RUNNER_BUSY");
|
|
// A retained envelope gets the same host hint as a live one.
|
|
expect((error as RunnerCommandError).hint).toContain(`stop-simulator-server ${UDID}`);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("waitForRunnerReady", () => {
|
|
it("polls status every 250ms until the first parsed response", async () => {
|
|
vi.useFakeTimers();
|
|
const { send, sent } = createFakeSend([
|
|
transportError(),
|
|
transportError(),
|
|
{ ok: true, data: { uptimeMs: 12, state: "idle" } },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const pending = waitForRunnerReady(client, { timeoutMs: 10_000 });
|
|
await vi.runAllTimersAsync();
|
|
await pending;
|
|
|
|
expect(sent).toHaveLength(3);
|
|
expect(sent.every((entry) => entry.body.command === "status")).toBe(true);
|
|
expect(sent.every((entry) => entry.options.readOnly === true)).toBe(true);
|
|
});
|
|
|
|
it("treats a parsed ok:false answer as ready: the transport provably works", async () => {
|
|
const { send, sent } = createFakeSend([
|
|
{ ok: false, error: { code: "RUNNER_BUSY", message: "busy" } },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
await waitForRunnerReady(client, { timeoutMs: 10_000 });
|
|
|
|
expect(sent).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps polling when the answer is not an envelope at all", async () => {
|
|
vi.useFakeTimers();
|
|
// The port comes from pickFreePort and the runner binds it only later, so
|
|
// anything else that grabbed it in between answers HTTP without speaking
|
|
// the envelope. Accepting that would end the 120s wait in success and
|
|
// leave the first real command to fail.
|
|
const { send, sent } = createFakeSend([
|
|
{ result: "some other listener" },
|
|
{ ok: true, data: { state: "idle" } },
|
|
]);
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const pending = waitForRunnerReady(client, { timeoutMs: 10_000 });
|
|
await vi.runAllTimersAsync();
|
|
await pending;
|
|
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
it("times out with a typed error when only non-envelope answers come back", async () => {
|
|
vi.useFakeTimers();
|
|
const { send } = createFakeSend(Array.from({ length: 50 }, () => ({ result: "not mine" })));
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const pending = waitForRunnerReady(client, { timeoutMs: 1_000 }).catch(
|
|
(caught: unknown) => caught
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
const error = await pending;
|
|
|
|
expect(error).toBeInstanceOf(IosDeviceTransportError);
|
|
expect((error as IosDeviceTransportError).kind).toBe("timeout");
|
|
// The last unrecognized answer is kept as the cause, so the timeout names
|
|
// what was actually on the port.
|
|
expect((error as IosDeviceTransportError).cause).toBeInstanceOf(RunnerCommandError);
|
|
});
|
|
|
|
it("times out with a typed error when the runner never answers", async () => {
|
|
vi.useFakeTimers();
|
|
const { send } = createFakeSend(Array.from({ length: 50 }, () => transportError()));
|
|
const client = createRunnerClient({ udid: UDID, port: PORT, send });
|
|
|
|
const pending = waitForRunnerReady(client, { timeoutMs: 1_000 }).catch(
|
|
(caught: unknown) => caught
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
const error = await pending;
|
|
|
|
expect(error).toBeInstanceOf(IosDeviceTransportError);
|
|
expect((error as IosDeviceTransportError).kind).toBe("timeout");
|
|
expect(send.mock.calls.length).toBeGreaterThan(1);
|
|
// The ready-timeout is user-reachable when the child is still alive. It
|
|
// is the one timeout that genuinely means not ready: no first envelope
|
|
// ever came, so it must not read as a mid-session transport failure (T44).
|
|
const signal = getFailureSignal(error);
|
|
expect(signal?.error_code).toBe(FAILURE_CODES.IOS_DEVICE_RUNNER_NOT_READY);
|
|
expect(signal?.failure_stage).toBe("ios_device_runner_ready_poll");
|
|
expect(signal?.error_kind).toBe("timeout");
|
|
});
|
|
});
|