Files
software-mansion__argent/packages/tool-server/test/keyboard-android.test.ts
Ignacy Łątka 44b38d490e fix(tool-server): inject Android keyboard/button over adb input (#449) (#455)
## What & why

`keyboard` and `button` drive Android through the simulator-server's
USB-HID transport (`pressKey` / `cmd:"button"`). The guest only receives
those HID events when the AVD exposes a hardware keyboard (`hw.keyboard
= yes`) — the default, but CI / headless AVDs are routinely created with
`hw.keyboard = no` (and `hw.mainKeys = no`), where the guest **silently
drops** them. Because that transport is fire-and-forget, both tools
returned `{ typed }` / `{ pressed }` **success while injecting
nothing**.

This still affects current `main`: after the keyboard refactor,
`makeAndroidImpl` routes Android **TV** to `adb input` but Android
**phones/tablets** still to `typeSimulatorServer` (the HID path).

Fixes #449 (found during 0.13.0 RC E2E testing on a `hw.keyboard=no` AVD
— `argent-test-ci-repro`, the config CI uses).

## Change

Route Android **phone/tablet** key / text / button injection through
`adb shell input text` and `input keyevent` (new
`utils/android-input.ts`), the same on-device transport Android TV and
Vega already use:

- **`keyboard`** — `platforms/android.ts` now sends the non-TV branch to
a `typeAndroidPhone` helper (adb `input`); the Android-TV and iOS
branches are untouched.
- **`button`** — the Android branch presses via `injectAndroidKeycode`;
iOS keeps the simulator-server HID path.

`adb input` lands regardless of `hw.keyboard`, on emulators (any config)
and physical devices alike, and a non-zero exit surfaces as a **thrown
error** instead of a silent no-op — directly addressing the
fire-and-forget concern already called out in the `button` tool. Touch
gestures are unchanged.

## Verification

- **Unit tests — 11/11 pass** (rebased on current `main`): new
`keyboard-android.test.ts` (vocabulary parity vs `NAMED_KEYS`, `input
text` shell-quoting incl. spaces, `input keyevent` mapping, empty-text
no-op, newline rejection) + updated `button.test.ts` (Android `back` now
asserts `KEYCODE_BACK` over adb).
- **eslint** on the changed files: clean.
- **Live tool-server** on a `hw.keyboard=no` AVD: the `keyboard` tool
now issues `adb … shell input text '…'` (vs the RC's HID `pressKey`),
and a failed inject **raises an error** rather than a fake success. The
`adb input` path was independently observed typing/deleting on this
exact AVD during RC testing (where the HID path silently no-op'd).
- Note: a full `tsc --build` / whole-suite run couldn't be completed in
my environment (the `argent-private` submodule isn't checked out, which
breaks the npm workspace install, and `main` added upstream deps my base
lacked). `platforms/android.ts` mirrors the existing `platforms/ios.ts`
branch structure one-for-one, and the pre-rebase revision of this change
passed `tsc --build` + eslint. CI here is authoritative for the full
typecheck.

## Reviewer notes

- `input text` receives the string as one shell-quoted argv token, so
spaces are preserved verbatim; newlines are rejected (they'd truncate
the `input` line) — callers use `key: "enter"`.
- `keys` counts by codepoint, matching the tv/vega/simulator-server
backends.
2026-07-10 12:51:24 +02:00

424 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Registry, type DeviceInfo } from "@argent/registry";
// Capture the adb command strings instead of shelling out to a real device.
// Keep `shellQuote` real (android-input relies on it) — only stub the transport
// and the `isAndroidTv` runtime probe (so the phone/TV branch is deterministic).
// `vi.hoisted` so the mock fns exist when the hoisted `vi.mock` factory runs.
const { adbShell, isAndroidTv } = vi.hoisted(() => ({
// Typed params so `adbShell.mock.calls[0]` is a `[serial, cmd, opts?]` tuple
// (an untyped `vi.fn(async () => "")` infers a zero-arg call and TS2493s on
// destructuring — vitest transforms tests with esbuild, so only `tsc` catches it).
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,
}));
// Stub the TV backend so the routing test can prove a TV target goes here and a
// phone target does not, without driving the real focus daemon.
const { typeTv } = vi.hoisted(() => ({
typeTv: vi.fn(async (): Promise<{ typed: string; keys: number }> => ({ typed: "TV", keys: 0 })),
}));
vi.mock("../src/tools/keyboard/platforms/tv", () => ({ typeTv }));
// `dispatchByPlatform` preflights the android branch's declared `requires`
// (`["adb"]`) via `ensureDeps` BEFORE the handler runs. Stub it so it resolves
// by default (this file's handler-level tests never reach it); the preflight
// test below overrides it to reject. Keep `DependencyMissingError` real via the
// spread so `instanceof` works.
const { ensureDeps } = vi.hoisted(() => ({
ensureDeps: vi.fn(async (_deps: readonly string[]): Promise<void> => {}),
}));
vi.mock("../src/utils/check-deps", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/utils/check-deps")>()),
ensureDeps,
}));
import {
ANDROID_NAMED_KEYCODES,
ANDROID_BUTTON_KEYCODES,
assertTypeableAndroidText,
injectAndroidText,
injectAndroidNamedKey,
} from "../src/utils/android-input";
import { NAMED_KEYS } from "../src/tools/keyboard/key-codes";
import { InvalidToolInputError } from "../src/utils/capability";
import { makeAndroidImpl } from "../src/tools/keyboard/platforms/android";
import { createKeyboardTool } from "../src/tools/keyboard";
import { DependencyMissingError } from "../src/utils/check-deps";
import type { KeyboardParams } from "../src/tools/keyboard/types";
import { BUTTONS_BY_PLATFORM } from "../src/tools/button";
const SERIAL = "emulator-5554";
describe("android-input — keycode maps", () => {
it("covers every keyboard NAMED_KEYS entry (adb map ⊇ HID map vocabulary)", () => {
for (const name of Object.keys(NAMED_KEYS)) {
expect(ANDROID_NAMED_KEYCODES[name], `missing android keycode for "${name}"`).toBeTypeOf(
"number"
);
}
});
it("has a keycode for every button the button tool accepts on Android", () => {
// Derive from the SOURCE set (button/index.ts) rather than a hardcoded list,
// so adding a button to BUTTONS_BY_PLATFORM.android without a matching
// keycode fails here — otherwise button/index.ts would inject
// `input keyevent undefined` (the `!` assertion hides it at compile time).
for (const button of BUTTONS_BY_PLATFORM.android) {
expect(ANDROID_BUTTON_KEYCODES[button], `missing keycode for "${button}"`).toBeTypeOf(
"number"
);
}
expect(ANDROID_BUTTON_KEYCODES.back).toBe(4); // KEYCODE_BACK
});
it("pins the exact android.view.KeyEvent code for every named key (not self-referential)", () => {
// Assert against literal keycodes, independent of the source constant, so a
// typo or a wrong value is caught — the injection tests below compare to the
// constant itself and would pass even if the value were wrong.
expect(ANDROID_NAMED_KEYCODES).toEqual({
"enter": 66,
"return": 66,
"escape": 111,
"esc": 111,
"backspace": 67,
"delete": 67,
"tab": 61,
"space": 62,
"arrow-up": 19,
"arrow-down": 20,
"arrow-left": 21,
"arrow-right": 22,
"f1": 131,
"f2": 132,
"f3": 133,
"f4": 134,
"f5": 135,
"f6": 136,
"f7": 137,
"f8": 138,
"f9": 139,
"f10": 140,
"f11": 141,
"f12": 142,
});
});
it("pins the exact keycode for every Android hardware button (catches swapped codes)", () => {
// `toEqual` (exact) so e.g. swapping volumeUp/volumeDown — green under a
// presence-only check — turns this red.
expect(ANDROID_BUTTON_KEYCODES).toEqual({
home: 3,
back: 4,
power: 26,
volumeUp: 24,
volumeDown: 25,
appSwitch: 187,
});
});
it("maps `delete` to backspace (KEYCODE_DEL), matching iOS and the HID vocabulary", () => {
// NAMED_KEYS (key-codes.ts) gives both `backspace` and `delete` HID usage 42
// (Keyboard DELETE/Backspace), and iOS types `delete` as a backspace — so a
// named key means the same thing on every platform. Android must therefore map
// `delete` to KEYCODE_DEL (67, backspace), NOT KEYCODE_FORWARD_DEL (112).
expect(ANDROID_NAMED_KEYCODES.delete).toBe(67);
expect(ANDROID_NAMED_KEYCODES.delete).toBe(ANDROID_NAMED_KEYCODES.backspace);
});
});
describe("android-input — injection", () => {
it("types text via `input text`, shell-quoted, spaces preserved", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "wi fi");
expect(adbShell).toHaveBeenCalledTimes(1);
const [serial, cmd] = adbShell.mock.calls[0]!;
expect(serial).toBe(SERIAL);
expect(cmd).toBe("input text 'wi fi'");
});
it("does not shell out for empty text", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "");
expect(adbShell).not.toHaveBeenCalled();
});
it("presses a named key via `input keyevent <code>`", async () => {
adbShell.mockClear();
await injectAndroidNamedKey(SERIAL, "enter");
expect(adbShell).toHaveBeenCalledWith(
SERIAL,
`input keyevent ${ANDROID_NAMED_KEYCODES.enter}`,
expect.anything()
);
});
it("case-folds the named key so uppercase input works (parity with the sim-server path)", async () => {
// `keyboard`'s `key` is a free `z.string()` (not a lowercase enum), and the
// sim-server backend folds case (`NAMED_KEYS[key.toLowerCase()]`), so
// `key: "Enter"` / "ENTER" is supported cross-platform input. The android path
// must fold it too — dropping `.toLowerCase()` here would make uppercase keys
// 400 as "Unknown key" on android only. Assert the literal keycode so the fold
// (ENTER → enter → KEYCODE_ENTER) is what's verified, not the constant itself.
adbShell.mockClear();
await injectAndroidNamedKey(SERIAL, "ENTER");
expect(adbShell).toHaveBeenCalledWith(SERIAL, "input keyevent 66", expect.anything());
});
it("rejects an unknown named key as invalid input (→ HTTP 400)", async () => {
// InvalidToolInputError (not a plain Error) so the HTTP layer maps it to 400
// — an unknown key is a caller mistake, not an internal server fault.
await expect(injectAndroidNamedKey(SERIAL, "nope")).rejects.toThrow(/Unknown key/);
await expect(injectAndroidNamedKey(SERIAL, "nope")).rejects.toBeInstanceOf(
InvalidToolInputError
);
});
it("rejects newlines as invalid input (→ HTTP 400) rather than silently truncating", () => {
expect(() => assertTypeableAndroidText("line1\nline2")).toThrow(/newline/);
expect(() => assertTypeableAndroidText("line1\nline2")).toThrow(InvalidToolInputError);
expect(() => assertTypeableAndroidText("ok")).not.toThrow();
});
it("rejects a prototype-chain key name as an unknown key, issuing no adb call", async () => {
// `key` is a free string, so a lookup on the plain keycode object would let
// `Object.prototype` members through: `ANDROID_NAMED_KEYCODES["constructor"]`
// is a function (not nullish), so without an own-property guard it would
// shell out `input keyevent <garbage>` (a 500) instead of a clean 400.
adbShell.mockClear();
for (const proto of ["constructor", "__proto__", "toString", "hasOwnProperty"]) {
await expect(injectAndroidNamedKey(SERIAL, proto)).rejects.toBeInstanceOf(
InvalidToolInputError
);
}
expect(adbShell).not.toHaveBeenCalled();
});
});
describe("android-input — `%` types verbatim (no `%s`→space corruption)", () => {
// `adb input text`'s InputShellCommand.sendText rewrites `%s`→space and does NOT
// unescape `%%`, so a single `input text` corrupts `%`-bearing input. We split so
// every `%` ends a segment (one `input text` per segment) — a `%` is then never
// immediately followed by `s` on the device, and the segments re-join verbatim.
it("splits `100%safe` so `%` never precedes `s` at the device boundary", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "100%safe");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual([
"input text '100%'",
"input text 'safe'",
]);
});
it("types the literal sequence `%s` instead of a space", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "%s");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input text '%'", "input text 's'"]);
});
it("does not collapse `%%`", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "%%");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input text '%'", "input text '%'"]);
});
it("keeps a trailing `%` in its own segment (`50% off`)", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "50% off");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input text '50%'", "input text ' off'"]);
});
it("uses a single `input text` for `%`-free text", async () => {
adbShell.mockClear();
await injectAndroidText(SERIAL, "hello world");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input text 'hello world'"]);
});
});
describe("android-input — shell-metachar safety (single quoted argv token)", () => {
// shellQuote wraps the text in single quotes so the device's /bin/sh treats it
// as one inert token — quotes / `&` / `$(...)` / backticks never execute.
it.each([
["single quotes", "o'brien", "input text 'o'\\''brien'"],
["ampersand", "a&b && c", "input text 'a&b && c'"],
["command substitution", "$(id) `id`", "input text '$(id) `id`'"],
["redirect / pipe / glob", "a | b > c *", "input text 'a | b > c *'"],
])("quotes %s so the device shell can't interpret it", async (_label, text, expected) => {
adbShell.mockClear();
await injectAndroidText(SERIAL, text);
expect(adbShell.mock.calls.map((c) => c[1])).toEqual([expected]);
});
});
describe("android-input — rejects text `adb input text` can't type", () => {
it("rejects emoji with a clear error naming the character", async () => {
await expect(injectAndroidText(SERIAL, "hi 😀")).rejects.toThrow(/😀/);
await expect(injectAndroidText(SERIAL, "hi 😀")).rejects.toThrow(/printable ASCII/);
});
it("rejects accented / non-ASCII letters (silently dropped by input text) as HTTP-400 input", async () => {
await expect(injectAndroidText(SERIAL, "café")).rejects.toThrow(/é/);
// Caller input error, not a 500: must be InvalidToolInputError so `keyboard`
// with `café` on Android returns 400, not a generic internal error.
await expect(injectAndroidText(SERIAL, "café")).rejects.toBeInstanceOf(InvalidToolInputError);
});
it("does not shell out at all when the text is rejected", async () => {
adbShell.mockClear();
await expect(injectAndroidText(SERIAL, "😀")).rejects.toThrow();
expect(adbShell).not.toHaveBeenCalled();
});
it("still accepts the full printable-ASCII range (letters, digits, punctuation)", () => {
expect(() =>
assertTypeableAndroidText("hello WORLD 123 !@#$%^&*()_+-=[]{};:'\",.<>/?`~|\\")
).not.toThrow();
});
it("rejects the exact control-char edges just outside printable ASCII (DEL, low controls)", () => {
// The printable window is [0x20, 0x7e]. Pin BOTH boundaries with the chars one
// step past each edge, so widening the bound (e.g. `> 0x7e` → `> 0x7f`, letting
// DEL through, or `< 0x20` → `< 0x10`, letting low controls through) fails here
// — the accept/reject tests otherwise only use codepoints far from the edges.
expect(() => assertTypeableAndroidText("\x7f")).toThrow(InvalidToolInputError); // DEL, one past `~`
expect(() => assertTypeableAndroidText("\x1f")).toThrow(InvalidToolInputError); // unit-separator, one below space
expect(() => assertTypeableAndroidText("\x00")).toThrow(InvalidToolInputError); // NUL
// ...and the two printable edges themselves stay accepted.
expect(() => assertTypeableAndroidText(" ")).not.toThrow(); // 0x20 (space)
expect(() => assertTypeableAndroidText("~")).not.toThrow(); // 0x7e (tilde)
});
});
// Exercises `makeAndroidImpl().handler` end-to-end (the piece the low-level
// helper tests above never invoke): the isAndroidTv phone-vs-TV branch, the
// `keys` count, and the `{ typed, keys }` result shape.
describe("android keyboard impl — routing, keys count, result shape", () => {
const impl = makeAndroidImpl(new Registry());
const phone = { id: SERIAL, platform: "android", kind: "handset" } as unknown as DeviceInfo;
beforeEach(() => {
adbShell.mockClear();
typeTv.mockClear();
isAndroidTv.mockReset();
isAndroidTv.mockResolvedValue(false);
});
it("routes a non-TV android target to the adb phone path (not typeTv)", async () => {
const res = await impl.handler({}, { udid: SERIAL, text: "hi there" } as KeyboardParams, phone);
// `keys` = 8 codepoints; `typed` echoes the text; text goes over `input text`.
expect(res).toEqual({ typed: "hi there", keys: 8 });
expect(typeTv).not.toHaveBeenCalled();
expect(adbShell).toHaveBeenCalledWith(SERIAL, "input text 'hi there'", expect.anything());
});
it("routes an android TV target to typeTv (focus daemon), never the phone path", async () => {
isAndroidTv.mockResolvedValue(true);
const sentinel = { typed: "TV", keys: 0 };
typeTv.mockResolvedValue(sentinel);
const res = await impl.handler({}, { udid: SERIAL, text: "hi" } as KeyboardParams, phone);
expect(res).toBe(sentinel);
// Phone injection must not fire for a TV target.
expect(adbShell).not.toHaveBeenCalled();
});
it("no-ops on an empty request (neither key nor text): { typed:'', keys:0 }, zero adb", async () => {
// The schema leaves both `key` and `text` optional with no refinement, so an
// empty request is a silent no-op returning { typed:"", keys:0 } and issuing
// no adb call — the same contract every keyboard backend (simulator-server,
// tv, vega) follows. Pin it so a future change to that behaviour (e.g. making
// it throw) is a deliberate, visible edit rather than an unnoticed drift.
const res = await impl.handler({}, { udid: SERIAL } as KeyboardParams, phone);
expect(res).toEqual({ typed: "", keys: 0 });
expect(adbShell).not.toHaveBeenCalled();
});
it("counts a named key as 1 and returns it as `typed` when no text is given", async () => {
const res = await impl.handler({}, { udid: SERIAL, key: "enter" } as KeyboardParams, phone);
expect(res).toEqual({ typed: "enter", keys: 1 });
expect(adbShell).toHaveBeenCalledWith(SERIAL, "input keyevent 66", expect.anything());
});
it("counts key + text together (1 + codepoints), emits BOTH, returns text as `typed`", async () => {
const res = await impl.handler(
{},
{ udid: SERIAL, key: "enter", text: "abc" } as KeyboardParams,
phone
);
expect(res).toEqual({ typed: "abc", keys: 4 });
// Assert the exact ordered sequence, not just presence: the key fires BEFORE
// the text (source contract — press the named key, then type, matching the
// simulator-server / vega backends). `toEqual` catches both a dropped keyevent
// when text co-occurs AND a silent reorder to text-before-key; a `toContain`
// pair would miss the reorder.
const cmds = adbShell.mock.calls.map((c) => c[1]);
expect(cmds).toEqual(["input keyevent 66", "input text 'abc'"]); // KEYCODE_ENTER, then text
});
it("rejects a key + un-typeable text request with NO on-device side effect", async () => {
// The text is validated up front, so a combined request whose text can't be
// typed must reject before the key is pressed — not press the key and then
// 400 (which would submit a form on `key:"enter"` and double-fire on retry).
adbShell.mockClear();
await expect(
impl.handler({}, { udid: SERIAL, key: "enter", text: "café" } as KeyboardParams, phone)
).rejects.toBeInstanceOf(InvalidToolInputError);
// Neither the keyevent nor any text segment reached the device.
expect(adbShell).not.toHaveBeenCalled();
});
it("surfaces an adb transport failure as a throw (no silent success — the #449 fix)", async () => {
// The whole point of moving off the fire-and-forget HID transport: a failed
// inject must propagate, not resolve `{ typed }` while nothing was typed.
adbShell.mockClear();
adbShell.mockRejectedValueOnce(new Error("adb: device offline"));
await expect(
impl.handler({}, { udid: SERIAL, text: "hi" } as KeyboardParams, phone)
).rejects.toThrow(/device offline/);
});
});
// The routing/injection tests above call `makeAndroidImpl().handler` directly,
// which bypasses `dispatchByPlatform`'s host-binary preflight — so nothing there
// pins the `requires: ["adb"]` on the android branch. Drive the tool's real
// `execute` (which IS `dispatchByPlatform`) so removing that declaration is a red
// test rather than a silent regression to a deep ENOENT on an adb-less host.
describe("keyboard tool — android adb preflight (via dispatchByPlatform)", () => {
beforeEach(() => {
adbShell.mockClear();
ensureDeps.mockClear();
ensureDeps.mockResolvedValue(undefined);
isAndroidTv.mockReset();
isAndroidTv.mockResolvedValue(false);
});
it("preflights `adb` before the handler; a missing binary fails closed as a DependencyMissingError", async () => {
ensureDeps.mockRejectedValueOnce(
new DependencyMissingError(["adb"], "install android-platform-tools")
);
const tool = createKeyboardTool(new Registry());
await expect(tool.execute({}, { udid: SERIAL, text: "hi" })).rejects.toBeInstanceOf(
DependencyMissingError
);
// The preflight ran with the android branch's declared dep...
expect(ensureDeps).toHaveBeenCalledWith(["adb"]);
// ...and fails closed: no `adb input` is issued when the preflight rejects.
// (Dropping `requires: ["adb"]` skips the preflight, the handler runs, and
// this `input text` fires — turning the assertions above red.)
expect(adbShell).not.toHaveBeenCalled();
});
it("runs the handler over adb once the preflight passes", async () => {
const tool = createKeyboardTool(new Registry());
await expect(tool.execute({}, { udid: SERIAL, text: "hi" })).resolves.toEqual({
typed: "hi",
keys: 2,
});
expect(ensureDeps).toHaveBeenCalledWith(["adb"]);
expect(adbShell).toHaveBeenCalledWith(SERIAL, "input text 'hi'", expect.anything());
});
});