mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
44b38d490e
## 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.
424 lines
19 KiB
TypeScript
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());
|
|
});
|
|
});
|