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