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 -->
423 lines
20 KiB
TypeScript
423 lines
20 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { FAILURE_CODES, getFailureSignal, Registry } from "@argent/registry";
|
|
import { InvalidToolInputError } from "../src/utils/capability";
|
|
import { CLIENT_UNSAFE_TOP_LEVEL_KEYWORDS, advertisedSchema } from "./helpers/catalog";
|
|
|
|
// Every backend's transport is stubbed, so "did anything reach the device" is
|
|
// observable per platform: `pressKey` (simulator-server HID), `adbShell`
|
|
// (`adb input`), `dispatchKeyEvent` (CDP), and the vega injectors.
|
|
const { adbShell, isAndroidTv } = vi.hoisted(() => ({
|
|
adbShell: vi.fn(async (_serial: string, _cmd: string, _opts?: unknown): Promise<string> => ""),
|
|
isAndroidTv: vi.fn(async (_serial: string): Promise<boolean> => false),
|
|
}));
|
|
vi.mock("../src/utils/adb", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/adb")>()),
|
|
adbShell,
|
|
isAndroidTv,
|
|
}));
|
|
|
|
// The android and vega branches declare `requires: ["adb"]`; stub the preflight
|
|
// so these tests don't depend on an adb binary on the host.
|
|
vi.mock("../src/utils/check-deps", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/check-deps")>()),
|
|
ensureDeps: vi.fn(async (_deps: readonly string[]): Promise<void> => {}),
|
|
}));
|
|
|
|
// The ios branch runtime-probes the TV kind by shelling out to `xcrun`; a real
|
|
// probe would make these tests host-dependent (and slow).
|
|
vi.mock("../src/utils/ios-devices", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/ios-devices")>()),
|
|
isTvOsSimulator: vi.fn(async (_udid: string): Promise<boolean> => false),
|
|
}));
|
|
|
|
vi.mock("../src/utils/vega-input", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/vega-input")>()),
|
|
injectVegaText: vi.fn(async () => {}),
|
|
injectVegaNamedKey: vi.fn(async () => {}),
|
|
}));
|
|
|
|
import { injectVegaNamedKey, injectVegaText } from "../src/utils/vega-input";
|
|
import { createKeyboardTool } from "../src/tools/keyboard";
|
|
import { NAMED_KEYS } from "../src/tools/keyboard/key-codes";
|
|
|
|
const pressKey = vi.fn((_direction: "Down" | "Up", _keyCode: number) => {});
|
|
// Params typed so `mock.calls[n][0]` is the event, not `never` — vitest transforms
|
|
// tests with esbuild, so only `tsc --noEmit` catches an untyped `vi.fn()` here.
|
|
const dispatchKeyEvent = vi.fn(async (_event: { type: string; key?: string }) => {});
|
|
|
|
/** A registry whose service resolution hands back both HID and CDP fakes. */
|
|
function registry(): Registry {
|
|
const r = new Registry();
|
|
vi.spyOn(r, "resolveService").mockResolvedValue({ pressKey, dispatchKeyEvent } as never);
|
|
return r;
|
|
}
|
|
|
|
// The tool routes by udid SHAPE (see utils/device-info.ts `classifyDevice`), so
|
|
// these ids are what pick each backend. `pressedBackspace` reads the transport
|
|
// for the key that was actually asked for: `typed` echoes `params.key` straight
|
|
// back, so it would pass a backend that ignored `params.key` and pressed a
|
|
// hardcoded Enter.
|
|
const BACKENDS = [
|
|
{
|
|
platform: "ios",
|
|
udid: "809A848B-1671-4A72-B9C9-B1683D95973E",
|
|
injections: () => pressKey.mock.calls.length,
|
|
// HID usage 42 = Keyboard DELETE/Backspace (key-codes.ts NAMED_KEYS).
|
|
pressedBackspace: () =>
|
|
pressKey.mock.calls.some((c) => c[0] === "Down" && c[1] === NAMED_KEYS.backspace),
|
|
},
|
|
{
|
|
platform: "android",
|
|
udid: "emulator-5554",
|
|
injections: () => adbShell.mock.calls.length,
|
|
// KEYCODE_DEL = 67.
|
|
pressedBackspace: () => adbShell.mock.calls.some((c) => c[1] === "input keyevent 67"),
|
|
},
|
|
{
|
|
platform: "chromium",
|
|
udid: "chromium-cdp-9222",
|
|
injections: () => dispatchKeyEvent.mock.calls.length,
|
|
pressedBackspace: () => dispatchKeyEvent.mock.calls.some((c) => c[0].key === "Backspace"),
|
|
},
|
|
{
|
|
platform: "vega",
|
|
udid: "amazon-4a27df03c9777152",
|
|
injections: () =>
|
|
vi.mocked(injectVegaText).mock.calls.length + vi.mocked(injectVegaNamedKey).mock.calls.length,
|
|
pressedBackspace: () =>
|
|
vi.mocked(injectVegaNamedKey).mock.calls.some((c) => c[0] === "backspace"),
|
|
},
|
|
];
|
|
|
|
/** Assert the rejection is the 400-class one the exclusivity rule raises. */
|
|
async function expectCombinedRejection(p: Promise<unknown>): Promise<void> {
|
|
const err = await p.then(
|
|
() => {
|
|
throw new Error("expected the combined text+key call to reject, but it resolved");
|
|
},
|
|
(e: unknown) => e
|
|
);
|
|
expect(err).toBeInstanceOf(InvalidToolInputError);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.KEYBOARD_TEXT_AND_KEY_COMBINED);
|
|
}
|
|
|
|
/** Assert the rejection is the 400-class one an unusable `key` value raises. */
|
|
async function expectUnsupportedKey(p: Promise<unknown>): Promise<Error> {
|
|
const err = await p.then(
|
|
() => {
|
|
throw new Error("expected the empty-key call to reject, but it resolved");
|
|
},
|
|
(e: unknown) => e as Error
|
|
);
|
|
expect(err).toBeInstanceOf(InvalidToolInputError);
|
|
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.KEYBOARD_KEY_UNSUPPORTED);
|
|
return err;
|
|
}
|
|
|
|
/** Drive a combined call and hand back the error it rejected with. */
|
|
async function combinedError(params: Record<string, unknown>): Promise<Error> {
|
|
return createKeyboardTool(registry())
|
|
.execute({}, { udid: "emulator-5554", ...params } as never)
|
|
.then(
|
|
() => {
|
|
throw new Error("expected the combined text+key call to reject");
|
|
},
|
|
(e: unknown) => e as Error
|
|
);
|
|
}
|
|
|
|
// `text` and `key` in one call had no meaning a caller could rely on: the same
|
|
// request reads as "type, then submit" for `key:"enter"` and as "delete, then
|
|
// type" for `key:"backspace"`, and each backend had to pick an order (#579). The
|
|
// tool now rejects the combination in `execute`, ahead of the platform dispatch,
|
|
// so no backend sees the shape and the sequence is expressed as two calls —
|
|
// batched into one `run-sequence` when the round-trip matters.
|
|
describe("keyboard — `text` and `key` are mutually exclusive", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
isAndroidTv.mockResolvedValue(false);
|
|
});
|
|
|
|
for (const { platform, udid, injections, pressedBackspace } of BACKENDS) {
|
|
it(`${platform}: rejects a combined text+key call with nothing injected`, async () => {
|
|
const r = registry();
|
|
await expectCombinedRejection(
|
|
createKeyboardTool(r).execute({}, { udid, text: "hi", key: "enter", delayMs: 0 })
|
|
);
|
|
// Rejected before the dispatch, so the backend is never reached: no keys
|
|
// injected on any of the four.
|
|
expect(injections()).toBe(0);
|
|
// Adds signal on ios and chromium only — those resolve a service (and
|
|
// would spawn one) on a call that gets through. Android injects through
|
|
// `adbShell` directly and the vega branch never references the registry,
|
|
// so a SUCCESSFUL call resolves nothing there either and this line cannot
|
|
// fail on those two iterations. Kept because it holds for all four and
|
|
// guards the two that can regress; `injections()` above is what carries
|
|
// the android and vega rows.
|
|
expect(r.resolveService).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Positive control for the test above — which would otherwise also pass
|
|
// against a harness that cannot reach this backend at all. Both halves run
|
|
// separately here, so a guard widened to reject either one alone turns red.
|
|
it(`${platform}: still injects text alone, and a key alone`, async () => {
|
|
const tool = createKeyboardTool(registry());
|
|
|
|
await expect(tool.execute({}, { udid, text: "hi", delayMs: 0 })).resolves.toMatchObject({
|
|
typed: "hi",
|
|
});
|
|
expect(injections()).toBeGreaterThan(0);
|
|
|
|
vi.clearAllMocks();
|
|
// `backspace`, not `enter`: it is the key every other backend test also
|
|
// presses, so pressing the one that was ASKED for is what is pinned here.
|
|
await expect(tool.execute({}, { udid, key: "backspace", delayMs: 0 })).resolves.toMatchObject(
|
|
{
|
|
typed: "backspace",
|
|
}
|
|
);
|
|
expect(pressedBackspace(), `${platform} pressed a key other than backspace`).toBe(true);
|
|
});
|
|
}
|
|
|
|
it("rejects on the request's shape, not on what the values would do", async () => {
|
|
// An empty half still names both parameters. Rejecting it keeps the rule
|
|
// statable as "one or the other" — a truthiness check would instead need an
|
|
// empty-string carve-out documented on both parameters.
|
|
const tool = createKeyboardTool(registry());
|
|
await expectCombinedRejection(
|
|
tool.execute({}, { udid: "emulator-5554", text: "", key: "enter" })
|
|
);
|
|
await expectCombinedRejection(tool.execute({}, { udid: "emulator-5554", text: "hi", key: "" }));
|
|
expect(adbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves an empty request a no-op rather than requiring one of the two", async () => {
|
|
// The rule is at-most-one, not exactly-one. `{}` stays the documented no-op
|
|
// it was before this guard, so a guard written as "exactly one of text/key"
|
|
// — the natural misreading of the parameter docs — is red here.
|
|
await expect(
|
|
createKeyboardTool(registry()).execute({}, { udid: "emulator-5554" })
|
|
).resolves.toEqual({ typed: "", keys: 0 });
|
|
expect(adbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("physical iOS device: the empty request is the simulator's no-op, reached device-free", async () => {
|
|
// The device branch was the one backend that errored on `{}`. Its no-op
|
|
// must return before the runner is resolved AND before the tracked-app
|
|
// requirement: no launch-app ran here, so a branch that still called
|
|
// requireCurrentIosDeviceApp would reject with "No app is under
|
|
// automation". Resolving at all is what pins the early return.
|
|
const simResult = await createKeyboardTool(registry()).execute(
|
|
{},
|
|
{ udid: "809A848B-1671-4A72-B9C9-B1683D95973E" }
|
|
);
|
|
const r = registry();
|
|
const deviceResult = await createKeyboardTool(r).execute(
|
|
{},
|
|
// Physical-iOS UDID shape (8 hex, dash, 16 hex) routes to the iosDevice
|
|
// branch (see utils/device-info.ts).
|
|
{ udid: "00008110-000978540290401E" }
|
|
);
|
|
expect(deviceResult).toEqual({ typed: "", keys: 0 });
|
|
expect(deviceResult).toEqual(simResult);
|
|
// Unlike the simulator branch (which resolves simulator-server before its
|
|
// no-op return), the device branch resolves nothing.
|
|
expect(r.resolveService).not.toHaveBeenCalled();
|
|
expect(pressKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects before resolving a secret placeholder", async () => {
|
|
// The guard sits above `resolveSecretPlaceholders`, so a combined request
|
|
// never reads an `ARGENT_SECRET_*` value — and the caller gets the error that
|
|
// is actionable instead of one about the unknown placeholder name.
|
|
await expectCombinedRejection(
|
|
createKeyboardTool(registry()).execute(
|
|
{},
|
|
{
|
|
udid: "emulator-5554",
|
|
text: "{{secret:NO_SUCH_SECRET_FOR_THIS_TEST}}",
|
|
key: "enter",
|
|
}
|
|
)
|
|
);
|
|
expect(adbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("names the run-sequence replacement in the error message", async () => {
|
|
// The message is the whole migration path for an agent that copied the old
|
|
// combined example; without it the only signal is "not both". It has to name
|
|
// `run-sequence` specifically, because that is the form that keeps the two
|
|
// steps in one round-trip and one auto-screenshot.
|
|
const err = await combinedError({ text: "hi", key: "enter" });
|
|
expect(err.message).toMatch(/two `keyboard` steps in one `run-sequence`/);
|
|
// The literal retry, so an agent does not have to infer the split.
|
|
expect(err.message).toMatch(/{ text: "hello" } followed by { key: "enter" }/);
|
|
});
|
|
|
|
it("keeps a combined SECRET call inside the one run-sequence", async () => {
|
|
// The two remedies diverge here. The MCP auto-screenshot skip keys off a
|
|
// deep scan of the whole request (argent-mcp `containsSecretPlaceholder`),
|
|
// and `run-sequence` is itself in `AUTO_SCREENSHOT_TOOLS` — so the combined
|
|
// call and the one-run-sequence form both skip, while of two bare calls only
|
|
// the first does. The second, `{ key: "enter" }`, carries no placeholder and
|
|
// is screenshotted AFTER the key lands, handing the still-visible secret
|
|
// back as pixels. So the message has to say not just what to use but what
|
|
// NOT to fall back to.
|
|
const err = await combinedError({ text: "{{secret:APP_PASSWORD}}", key: "enter" });
|
|
expect(err.message).toMatch(/ONE `run-sequence`/);
|
|
expect(err.message).toMatch(/still-visible secret/);
|
|
// Still resolves nothing: the steer is a syntactic `.includes` on the
|
|
// placeholder, above `resolveSecretPlaceholders`, so an unset name does not
|
|
// turn this into an "unknown secret" error.
|
|
expect(adbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves the secret steer out of a plain combined call", async () => {
|
|
// Positive control for the test above: the secret sentence is specific to a
|
|
// placeholder-bearing request, so a plain one must not carry it — an
|
|
// unconditional sentence would pass that test while warning every caller
|
|
// about a secret they did not send.
|
|
const err = await combinedError({ text: "hi", key: "enter" });
|
|
expect(err.message).not.toMatch(/still-visible secret/);
|
|
});
|
|
|
|
it("still names the TV constraint the guard pre-empts", async () => {
|
|
// On a TV target `key` is rejected outright (platforms/tv.ts), so the second
|
|
// step this message prescribes — `{ key: "enter" }` — is a retry that cannot
|
|
// succeed there. Before the guard existed, a combined call on a TV got that
|
|
// diagnosis from the backend; the guard runs above the dispatch
|
|
// (deliberately, so nothing reaches a device) and would otherwise swallow it.
|
|
//
|
|
// The caveat is therefore carried statically, not by probing the target —
|
|
// distinguishing a TV kind is async. So this assertion holds for a phone
|
|
// udid too; what it pins is that the sentence survives edits to the message.
|
|
// The TV target is driven here because it is the shape that needs it.
|
|
isAndroidTv.mockResolvedValue(true);
|
|
const err = await combinedError({ text: "hi", key: "enter" });
|
|
expect(err.message).toMatch(/TV target/);
|
|
expect(err.message).toMatch(/tv-remote/);
|
|
});
|
|
});
|
|
|
|
// This tool decides `key` by presence, every backend dispatches it by
|
|
// truthiness (`if (params.key)`). So `{ key: "" }` used to clear both checks,
|
|
// reach a backend, press nothing, and still resolve `{ typed: "", keys: 0 }` —
|
|
// indistinguishable from a real press, while the tool description promises a
|
|
// failure for an unsupported key name, which `""` is. It is now rejected in
|
|
// `execute`, above the dispatch, so one guard covers every backend.
|
|
describe("keyboard — an empty `key` names no key", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
isAndroidTv.mockResolvedValue(false);
|
|
});
|
|
|
|
for (const { platform, udid, injections } of BACKENDS) {
|
|
it(`${platform}: rejects { key: "" } with nothing injected`, async () => {
|
|
await expectUnsupportedKey(
|
|
createKeyboardTool(registry()).execute({}, { udid, key: "", delayMs: 0 })
|
|
);
|
|
expect(injections()).toBe(0);
|
|
});
|
|
}
|
|
|
|
it("rejects it on a TV target too", async () => {
|
|
// The one backend that rejects `key` outright (platforms/tv.ts) read the
|
|
// empty one by truthiness as well, so it typed `""` and reported success
|
|
// instead. Its own rejection can therefore never be what covers this shape.
|
|
isAndroidTv.mockResolvedValue(true);
|
|
await expectUnsupportedKey(
|
|
createKeyboardTool(registry()).execute({}, { udid: "emulator-5554", key: "" })
|
|
);
|
|
});
|
|
|
|
it("leaves an empty `text` the no-op an omitted one is", async () => {
|
|
// Positive control, and the asymmetry the guard is built on: `key` names one
|
|
// member of a closed set, `text` carries a payload. A guard widened to
|
|
// "reject either empty value" turns this red.
|
|
await expect(
|
|
createKeyboardTool(registry()).execute({}, { udid: "emulator-5554", text: "", delayMs: 0 })
|
|
).resolves.toEqual({ typed: "", keys: 0 });
|
|
expect(adbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tells the caller nothing was pressed, and names omitting `key`", async () => {
|
|
// A caller that sent `""` usually built the value from something absent, so
|
|
// the actionable repair is to drop the parameter — not to guess at a name.
|
|
const err = await expectUnsupportedKey(
|
|
createKeyboardTool(registry()).execute({}, { udid: "emulator-5554", key: "" })
|
|
);
|
|
expect(err.message).toMatch(/nothing was pressed/);
|
|
expect(err.message).toMatch(/omit `key`/);
|
|
});
|
|
});
|
|
|
|
// The rule is enforced in `execute`, so a client that only ever calls the tool
|
|
// learns it from a 400. A client that validates arguments against the advertised
|
|
// schema, or constrains generation from it, never gets that far — and for this
|
|
// tool the schema CANNOT carry the rule: `not` is one of the top-level keywords
|
|
// #782 banned repo-wide, because the Messages API rejects a request whose tool
|
|
// schemas declare one and that 400 fails every tool in the request.
|
|
//
|
|
// Prose is therefore the only channel the constraint has, which makes the two
|
|
// `.describe()` texts load-bearing rather than decorative.
|
|
describe("keyboard — how the constraint reaches a client", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
isAndroidTv.mockResolvedValue(false);
|
|
});
|
|
|
|
it("leaves both halves optional in the advertised schema", () => {
|
|
const schema = advertisedSchema(createKeyboardTool(registry()))!;
|
|
expect(schema.type).toBe("object");
|
|
const required = schema.required as string[];
|
|
expect(required).toEqual(["udid"]);
|
|
});
|
|
|
|
it("does not try to encode the rule as a top-level combinator", () => {
|
|
// The tempting repair for the gap above, and the one that breaks every
|
|
// other tool in the same request. tool-input-schema-contract.test.ts holds
|
|
// this for the whole catalog; it is restated here because this tool is the
|
|
// one with a live cross-field rule and therefore the standing temptation.
|
|
const schema = advertisedSchema(createKeyboardTool(registry()))!;
|
|
expect(CLIENT_UNSAFE_TOP_LEVEL_KEYWORDS.filter((keyword) => keyword in schema)).toEqual([]);
|
|
});
|
|
|
|
it("states the constraint on `text` too, not only on `key`", () => {
|
|
// A caller reading only the parameter it is filling in must still see the
|
|
// rule; `boot-device` restates its exactly-one check in all four fields for
|
|
// the same reason. Both texts also name the remedy, since a constraint
|
|
// without one just moves the caller from a wrong call to a stuck one.
|
|
const { text, key } = createKeyboardTool(registry()).zodSchema!.shape;
|
|
expect(text.description).toMatch(/Cannot be combined with `key`/);
|
|
expect(text.description).toMatch(/run-sequence/);
|
|
expect(key.description).toMatch(/Cannot be combined with `text`/);
|
|
expect(key.description).toMatch(/run-sequence/);
|
|
});
|
|
|
|
it("accepts and rejects exactly the shapes the docs describe", async () => {
|
|
// The runtime table the prose above stands for. Without it "text OR key"
|
|
// is only ever asserted one shape at a time, and the neither/empty-half
|
|
// corners are where a rewritten guard drifts.
|
|
const tool = createKeyboardTool(registry());
|
|
const shapes: Array<[args: Record<string, unknown>, rejected: boolean]> = [
|
|
[{ text: "hi" }, false],
|
|
[{ key: "enter" }, false],
|
|
[{}, false], // neither is required — an empty request is a documented no-op
|
|
[{ text: "" }, false], // a payload: an empty one means what omitting it means
|
|
[{ key: "" }, true], // a name, and there is no key called ""
|
|
[{ text: "hi", key: "enter" }, true],
|
|
[{ text: "", key: "" }, true], // shape, not truthiness
|
|
];
|
|
|
|
for (const [args, rejected] of shapes) {
|
|
vi.clearAllMocks();
|
|
const outcome = await tool.execute({}, { udid: "emulator-5554", ...args } as never).then(
|
|
() => false,
|
|
() => true
|
|
);
|
|
expect(outcome, `runtime disagrees on ${JSON.stringify(args)}`).toBe(rejected);
|
|
}
|
|
});
|
|
});
|