Files
software-mansion__argent/packages/tool-server/test/keyboard-android.test.ts
Hubert Gancarczyk e4fbf7c5b7 test(keyboard,flow): pin the warning's scope, the no-field note, and two boundaries
Each gap below was found by editing the production line and watching the
suites stay green.

- Nothing pinned the SCOPE of the note-to-warning promotion. Narrowing it to
  `keyboard` left all 1226 flow tests green, so a tool returning a healthy
  `note` could be promoted for good with nothing to object. Cover both
  directions, at the top level and one level down inside `run-sequence`.
- The `run-sequence` spelling of a clear had no test at all, in either
  direction, nor a case for a nested report whose shape is unrecognised.
- `dispatchForResult`'s `?? null` was unpinned: dropping it turns a tool that
  returned nothing into the skip a hang-up produces, and no test noticed.
- The note's wording for `no_focused_input` / `not_editable` was unpinned -
  only the `why` fragment was asserted - with a contrast case that keeps the
  plain wording for a refusal.
- `WEDGED_RPC_MS` was loosely pinned: both arms had a test, the boundary did
  not, so widening 2s to 14s stayed green.
- `keyboard-android.test.ts` never reset the atomic cooldown, so the first
  clear to fail left every later test in the file on the injected path by a
  different route.
2026-08-24 12:20:06 +02:00

563 lines
27 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.
// `adbExecOutBinary` is mocked alongside `adbShell` because the hierarchy dump
// the clear reads back does NOT go through `adbShell` — it rides `exec-out`,
// hence the Buffer-shaped mock (see keyboard-clear.test.ts for why). Leaving it
// real makes `SERIAL` below address whatever emulator happens to be attached:
// these tests then dump a live device, and a clear's outcome depends on what is
// on its screen — locally a rescue run appears in the middle of the asserted
// command stream, while CI, with no emulator, sees none.
const { adbShell, adbExecOutBinary, 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> => ""),
adbExecOutBinary: vi.fn(
async (_serial: string, _cmd: string, _opts?: unknown): Promise<Buffer> => Buffer.from("")
),
isAndroidTv: vi.fn(async (_serial: string): Promise<boolean> => false),
}));
vi.mock("../src/utils/adb", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/utils/adb")>()),
adbShell,
adbExecOutBinary,
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,
__resetAtomicClearCooldown,
} 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("types the text's case verbatim (no fold at the `input text` sink)", async () => {
// No other fixture carries an uppercase letter INTO `injectAndroidText`.
// The two that are not all-lowercase reach it by neither route: "ENTER"
// goes to `injectAndroidNamedKey`, which folds case deliberately, and
// "hello WORLD 123 …" calls `assertTypeableAndroidText` on its own. So a
// `toLowerCase()` slip inside `injectAndroidText` — after the assert, where
// a shared normalise with the sibling `injectAndroidNamedKey`'s fold would
// land — was green against the whole suite.
// Nothing downstream guarantees case either: `shellQuote` only quotes and
// `assertTypeableAndroidText` accepts A-Z untouched, so the mirror-image
// `toUpperCase()` is the only direction that was red.
//
// `typed` echoes `params.text` rather than reading the device
// (platforms/android.ts:46), so a case regression answers
// `{ typed: "Passw0rd!", keys: 9 }` while the field holds `passw0rd!` —
// success to every consumer, android-only, and exactly where case-sensitive
// login fields and `{{secret:…}}` injection land.
adbShell.mockClear();
await injectAndroidText(SERIAL, "Passw0rd!");
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input text 'Passw0rd!'"]);
});
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("presses each named key with its own keycode (not one hardcoded value)", async () => {
// The map's literal values are pinned above; this pins that the lookup
// READS the map, for every entry in it, and that the resolved code reaches
// the wire. The other injection tests only ever press `enter` and
// `backspace`, so a lookup that mistyped one of the other 22 names — the
// realistic lookup/switch refactor slip — is green everywhere except here.
for (const [name, keycode] of Object.entries(ANDROID_NAMED_KEYCODES)) {
adbShell.mockClear();
await injectAndroidNamedKey(SERIAL, name);
expect(
adbShell.mock.calls.map((c) => c[1]),
`wrong keycode for "${name}"`
).toEqual([`input keyevent ${keycode}`]);
}
});
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.
// The NAME is part of the contract: it is what a caller needs to retry, and
// a bare `/Unknown key/` prefix left stripping it green in this file.
await expect(injectAndroidNamedKey(SERIAL, "nope")).rejects.toThrow(/Unknown key "nope"/);
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(() => {
// `mockReset`, not `mockClear`: several tests below queue one-shot values
// (`mockResolvedValueOnce` / `mockRejectedValueOnce`) to drive the failure
// paths, and `mockClear` empties `mock.calls` WITHOUT dropping an
// unconsumed queue entry. Every entry happens to be consumed today, so a
// leak is invisible — until one of those tests fails early, at which point
// its leftover rejection would surface in an unrelated test and hide the
// real failure. `mockReset` also restores the `async () => ""` given to
// `vi.fn`, so the default transport still resolves. Matches `isAndroidTv`
// on the next line.
// The atomic path marks a device whose helper refused to start, and the mark
// is module state keyed by serial — which every test here shares. Without
// this reset the first clear to fail leaves every LATER test on the injected
// path by a different route, and the file stops comparing like with like.
__resetAtomicClearCooldown();
adbShell.mockReset();
// An empty dump: `readHierarchy` finds no `<hierarchy>` tag, so the clear's
// read-back measures nothing and never redirects to the delete run. That is
// the shape these tests want — they assert the modern clear's own command
// stream. The rescue itself is covered in keyboard-clear.test.ts, where the
// dump is given real XML.
adbExecOutBinary.mockReset();
adbExecOutBinary.mockImplementation(async () => Buffer.from(""));
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 tool's rule on `text`/`key` is at-most-one, not exactly-one, so an
// empty request stays 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 every codepoint of `%`-split text, across all its segments", async () => {
// `%`-bearing text becomes one `input text` per segment, so `keys` is the
// one value that can silently become a per-segment count instead of a
// per-character one. The split itself is pinned above, against
// `injectAndroidText`; this is the backend's arithmetic over it.
adbShell.mockClear();
const res = await impl.handler({}, { udid: SERIAL, text: "100%safe" } as KeyboardParams, phone);
expect(adbShell.mock.calls.map((c) => c[1])).toEqual([
"input text '100%'",
"input text 'safe'",
]);
expect(res).toEqual({ typed: "100%safe", keys: 8 });
});
it("presses the key it was asked for, not a hardcoded Enter", async () => {
// Every other named-key assertion on this backend uses `key:"enter"`, so a
// path that ignored `params.key` and always pressed Enter would pass them
// all. `backspace` (67) is a second, distinct literal, so no single fixed
// keycode satisfies both. keyboard-backend-fidelity.test.ts holds the same
// property for the other three backends; android's lives here because its
// transport is a command line rather than an event stream.
adbShell.mockClear();
const res = await impl.handler({}, { udid: SERIAL, key: "backspace" } as KeyboardParams, phone);
expect(adbShell.mock.calls.map((c) => c[1])).toEqual(["input keyevent 67"]);
expect(res).toEqual({ typed: "backspace", keys: 1 });
});
it("rejects un-typeable text with NO on-device side effect", async () => {
// Android checks the whole string up front (unlike the iOS simulator and
// Chromium, which reject mid-string and leave a prefix), so a rejected
// request must leave the field byte-identical — the tool description says
// exactly that, and a per-segment check would break it without changing any
// success shape.
adbShell.mockClear();
await expect(
impl.handler({}, { udid: SERIAL, text: "café" } as KeyboardParams, phone)
).rejects.toBeInstanceOf(InvalidToolInputError);
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/);
});
it("chains two overlapping calls on one device rather than interleaving their adb writes", async () => {
// The clear holds a SELECTION across awaits: the select-all is its own `adb`
// invocation and the text that consumes it is another, so between them the
// field is fully selected. A concurrent call landing in there types over that
// selection and destroys the value — while BOTH calls return 200 with their
// own text as `typed`.
//
// Measured on API 36 before the chain covered this backend, `{clear,
// text:"AAAA"}` against `{clear, text:"BBBB"}` at 10/20/30/50ms: 4 of 4
// corrupt — ABABABAB, AABABBB, AAABABBB, AAAABBBB, where the only clean
// outcomes are AAAA and BBBB.
adbShell.mockClear();
const [a, b] = [
impl.handler({}, { udid: SERIAL, clear: true, text: "AAAA" } as KeyboardParams, phone),
impl.handler({}, { udid: SERIAL, clear: true, text: "BBBB" } as KeyboardParams, phone),
];
await Promise.all([a, b]);
// The exact ordered stream, not a grouping predicate: each run is
// select-all then text — the delete is skipped because the text replaces the
// selection (see `keepSelection`) — and the second run's first write must
// come after the first run's last. Unchained, the two runs suspend on
// alternate `adbShell` awaits and this comes back interleaved
// (keycombination, keycombination, text, text).
expect(adbShell.mock.calls.map((c) => c[1])).toEqual([
"input keycombination 113 29 2>&1",
"input text 'AAAA'",
"input keycombination 113 29 2>&1",
"input text 'BBBB'",
]);
});
it("leaves the chain immediately for a call whose client has hung up", async () => {
// `adb shell input` is not cancellable, so this is the only point a hang-up
// can be honoured — and without it a queue of abandoned calls still types
// every one of them out in full, at a device nobody is reading any more.
adbShell.mockClear();
await expect(
impl.handler({}, { udid: SERIAL, text: "hi" } as KeyboardParams, phone, {
signal: AbortSignal.abort(),
})
).rejects.toThrow();
expect(adbShell).not.toHaveBeenCalled();
});
});
// 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(() => {
// Same reason as the describe above — this one also has to survive a
// one-shot value left behind by the last test to run there, and the atomic
// path's per-serial mark left behind with it.
__resetAtomicClearCooldown();
adbShell.mockReset();
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());
});
});