mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
20b5e8b4f0
## Problem
`keyboard` accepted `text` and `key` in one call, and that shape has no
meaning a caller can rely on. The same request reads two ways:
- `{ text: "hello", key: "enter" }` — "type, then submit"
- `{ text: "abc", key: "backspace" }` — "delete, then type" reads just
as naturally
Whichever order a backend picks, the other reading silently corrupts the
field. The backends did not even agree: `typeAndroidPhone` pressed
`params.key` **before** `params.text`, while simulator-server, chromium
and vega all did the reverse and the tool's own description promised
"the text is typed first and the key is pressed after it".
## Change
**`keyboard` takes `text` or `key`, never both.** A combined call is
rejected in `execute`, above the platform dispatch, so it resolves no
`ARGENT_SECRET_*` value and reaches no device — no backend has to defend
against the shape.
**To mix text and a key, use `run-sequence`.** That is what the error
message, both parameter descriptions, the tool description and the skill
docs now say:
```json
{ "udid": "<UDID>", "steps": [
{ "tool": "keyboard", "args": { "text": "hello world" } },
{ "tool": "keyboard", "args": { "key": "enter" } }
]}
```
Two separate calls work too, but cost an extra round-trip — and are
**not** equivalent when the text carries a secret (see below).
### Why the rule is prose, not JSON Schema
An earlier revision hand-wrote `inputSchema` with `not: { required:
["text", "key"] }`. That is no longer legal: #782 banned every top-level
combinator repo-wide, `not` included, because the Messages API rejects a
request whose tool schemas declare one — and that 400 fails **every tool
in the request**, not just this one.
`tool-input-schema-contract.test.ts` enforces it across the catalog.
So the constraint is restated in **both** fields' `.describe()` (the
`boot-device` house rule) and in the tool description.
`keyboard-text-key-exclusive.test.ts` pins both texts, and pins that the
advertised schema stays combinator-free.
### What the error message carries
Three things a caller needs and cannot get elsewhere:
1. **The literal retry**, not an ellipsis the Android backend cannot
type.
2. **The TV caveat.** A TV target rejects `key` outright
(`platforms/tv.ts`), so the prescribed `{ key: "enter" }` step cannot
succeed there. The guard runs above the dispatch on purpose, so it would
otherwise swallow that diagnosis; the caveat is carried statically
rather than by an async TV probe.
3. **The secret steer**, only when `text` holds a `{{secret:…}}`
placeholder. The auto-screenshot skip is decided per tool call from the
whole request, so:
| call shape | auto-screenshot |
| --- | --- |
| one `run-sequence` carrying both steps | skipped |
| `{ text: "{{secret:…}}" }` alone | skipped |
| a separate `{ key: "enter" }` | **not** skipped — captured after the
key lands, and can hand the still-visible secret back as pixels |
The check is the syntactic `.includes` `flow-utils.ts` already uses, so
the guard still resolves nothing. A plain combined call does not carry
the sentence, with a positive control pinning that.
### Simplifications the rule makes possible
With at most one of the two ever reaching a backend, the
resolve-then-inject splits that existed only to order a combined call
fold back into their injectors (`android-input.ts`, `vega-input.ts`),
and each backend collapses to two exclusive branches.
### Fixed alongside
`run-sequence`'s description claimed "No screenshot is captured
automatically". `shouldAutoScreenshot("run-sequence")` is `true` with a
15000 ms settle cap, so that line was already false — and this change
makes it load-bearing, since the secret guidance rests on it.
## Compatibility
A stored flow or scripted `run-sequence` whose step is `{ tool:
keyboard, args: { text: "…", key: "enter" } }` now errors on replay, and
the steps after it are skipped. The recorder never emitted the combined
shape (`flow-add-step` executes the call before it appends the step), so
the blast radius is hand-authored flows and scripted `run-sequence`
calls — which is exactly what the old worked example taught. The fix is
to split the step in two.
## Test coverage
- **`keyboard-text-key-exclusive.test.ts`** (new, replaces
`keyboard-key-order.test.ts`) — the rejection on all four backends with
nothing injected and no service resolved, plus a positive control per
backend that text alone and a key alone still work; shape-not-truthiness
(`{ text: "", key: "enter" }` rejects); at-most-one rather than
exactly-one (`{}` stays a no-op); rejection before secret resolution;
the run-sequence retry, TV and secret sentences, with a positive control
against an unconditional secret steer; and the two channels the
constraint actually reaches a client through.
- **`keyboard-backend-fidelity.test.ts`** — absorbs what key-order
uniquely pinned. The unknown-key 400 now names the offending key on iOS
and chromium here, on android in `keyboard-android.test.ts`, and on vega
in `vega-injection.test.ts` (tightened from the bare `/Unknown Vega
key/` prefix), so stripping the name is red on every backend.
- **`keyboard-android.test.ts`** — the combined-call tests are gone.
What they uniquely covered survives as single-parameter tests: the
multi-segment `%` codepoint count, the pressed key's identity against a
second literal keycode, and un-typeable text rejected with no on-device
side effect. The exhaustive 24-keycode loop now runs through
`injectAndroidNamedKey` and asserts the emitted `input keyevent <code>`,
so it pins the wire and not just the lookup.
- **`interaction-messages.test.ts`** — the two formatters see different
shapes now. `startedMsg` renders before the rejection, so it still words
four (including the combined one); `completedMsg` runs only after
success, so it words three. Both empty-request branches stay pinned.
- **`keyboard-tv.test.ts`** — the TV `key` rejection is independent of
this guard, which only ever sees requests carrying both. Its header says
so, since the exclusivity message's TV clause depends on it.
- **`scripts/e2e-full/phases/30-android.sh`** — the combined call
becomes an `assert_reject`, and the prescribed `run-sequence` remedy is
driven end to end.
## Live verification
Isolated tool-server built from this branch (own port, shared MCP server
untouched), against the Android settings search field on an API 36 AVD,
read back out of the view hierarchy rather than off a screenshot:
| Call | HTTP | Field / result |
| --- | --- | --- |
| seed `{ text: "XYZ" }` | 200 | `XYZ` |
| `{ text: "abc", key: "backspace" }` | **400** | field still `XYZ` —
byte-identical, nothing typed |
| `run-sequence` [`{text:"abc"}`, `{key:"backspace"}`] | 200,
`completed: 2` | `XYZab` — exactly what the combined call used to
produce |
| `{ text: "hello" }` | 200 | `{ typed: "hello", keys: 5 }` |
| `{ key: "enter" }` | 200 | `{ typed: "enter", keys: 1 }` |
| `{}` | 200 | `{ typed: "", keys: 0 }` — still a no-op |
| `{ text: "", key: "enter" }` | **400** | shape, not truthiness |
| `{ text: "{{secret:APP_PASSWORD}}", key: "enter" }` | **400** |
message carries the ONE-`run-sequence` steer and the
still-visible-secret reason |
| `{ text: "hi", key: "enter" }` | **400** | message does **not** carry
the secret sentence |
`GET /tools` reports `["properties", "required", "type"]` for `keyboard`
— no top-level combinator.
Suite: `packages/tool-server` 336 files / 4041 passed,
`packages/registry` 90 passed, `packages/argent-mcp` 79 passed. `tsc
--build`, `tsc --noEmit -p tsconfig.test.json`, eslint (0 warnings),
prettier and knip all clean.
---------
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
488 lines
23 KiB
TypeScript
488 lines
23 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("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.
|
|
adbShell.mockReset();
|
|
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/);
|
|
});
|
|
});
|
|
|
|
// 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.
|
|
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());
|
|
});
|
|
});
|