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.
148 lines
7.2 KiB
TypeScript
148 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
// Keep the real module (blueprints import from it too) but neutralise the
|
|
// fire-and-forget WebSocket send so no real socket is opened during the test.
|
|
vi.mock("../src/utils/simulator-client", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/simulator-client")>()),
|
|
sendCommand: vi.fn(),
|
|
}));
|
|
|
|
// Android presses go over `adb shell input keyevent`; neutralise the real adb
|
|
// call so the test asserts wiring (which keycode) without a device attached.
|
|
vi.mock("../src/utils/android-input", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/android-input")>()),
|
|
injectAndroidKeycode: vi.fn(),
|
|
}));
|
|
|
|
// The Android branch preflights adb via `ensureDep("adb")` before injecting.
|
|
// Stub it (default: adb present, a no-op) so the happy-path tests don't depend
|
|
// on adb being installed on the test host (CI runs on a plain ubuntu image);
|
|
// individual tests override it to simulate a missing binary.
|
|
vi.mock("../src/utils/check-deps", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/check-deps")>()),
|
|
ensureDep: vi.fn(async () => {}),
|
|
}));
|
|
|
|
import { buttonTool, BUTTONS_BY_PLATFORM } from "../src/tools/button";
|
|
import { UnsupportedOperationError } from "../src/utils/capability";
|
|
import { ANDROID_BUTTON_KEYCODES, injectAndroidKeycode } from "../src/utils/android-input";
|
|
import { DependencyMissingError, ensureDep } from "../src/utils/check-deps";
|
|
import { sendCommand } from "../src/utils/simulator-client";
|
|
|
|
const iosUdid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
|
|
const androidUdid = "emulator-5554";
|
|
const services = { simulatorServer: {} } as never;
|
|
|
|
describe("button tool — per-platform validation", () => {
|
|
it("rejects `back` on iOS (no hardware back button) instead of a silent no-op", async () => {
|
|
await expect(
|
|
buttonTool.execute(services, { udid: iosUdid, button: "back" })
|
|
).rejects.toBeInstanceOf(UnsupportedOperationError);
|
|
});
|
|
|
|
it("rejects `actionButton` on Android", async () => {
|
|
await expect(
|
|
buttonTool.execute(services, { udid: androidUdid, button: "actionButton" })
|
|
).rejects.toBeInstanceOf(UnsupportedOperationError);
|
|
});
|
|
|
|
it("accepts `back` on Android and injects KEYCODE_BACK over adb (never the sim-server HID path)", async () => {
|
|
vi.mocked(injectAndroidKeycode).mockClear();
|
|
vi.mocked(ensureDep).mockClear();
|
|
vi.mocked(sendCommand).mockClear();
|
|
await expect(
|
|
buttonTool.execute(services, { udid: androidUdid, button: "back" })
|
|
).resolves.toEqual({ pressed: "back" });
|
|
// Routed to adb (not the HID sim-server path) so a stripped AVD can't drop it.
|
|
expect(injectAndroidKeycode).toHaveBeenCalledWith(androidUdid, ANDROID_BUTTON_KEYCODES.back);
|
|
// adb is preflighted so a missing binary fails with a 424 install hint
|
|
// rather than a generic 500 from deeper in the adb path.
|
|
expect(ensureDep).toHaveBeenCalledWith("adb");
|
|
// The mirror of the iOS test's `injectAndroidKeycode not called`: the Android
|
|
// press must NOT go over the simulator-server HID transport. Without this,
|
|
// dropping the android branch's `return` (so control falls through to the
|
|
// sim-server Down/Up path) would double-inject yet still resolve `{ pressed }`
|
|
// and stay green — silently moving the press back onto the transport #449
|
|
// exists to leave.
|
|
expect(sendCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("injects the matching keycode for EVERY Android button, not a hardcoded one", async () => {
|
|
// The `back` case above only proves one button. Drive every button the tool
|
|
// accepts on Android and assert each maps to its OWN keycode — a hardcoded
|
|
// index (e.g. always ANDROID_BUTTON_KEYCODES.back) would pass a single-button
|
|
// test while silently misfiring home / power / volume / appSwitch.
|
|
for (const button of BUTTONS_BY_PLATFORM.android) {
|
|
vi.mocked(injectAndroidKeycode).mockClear();
|
|
vi.mocked(sendCommand).mockClear();
|
|
await expect(buttonTool.execute(services, { udid: androidUdid, button })).resolves.toEqual({
|
|
pressed: button,
|
|
});
|
|
expect(injectAndroidKeycode).toHaveBeenCalledWith(
|
|
androidUdid,
|
|
ANDROID_BUTTON_KEYCODES[button]
|
|
);
|
|
// Every Android button goes over adb only — never the sim-server HID path.
|
|
expect(sendCommand).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("preflights adb before injecting so a missing binary surfaces as 424, not 500", async () => {
|
|
vi.mocked(injectAndroidKeycode).mockClear();
|
|
vi.mocked(ensureDep).mockRejectedValueOnce(
|
|
new DependencyMissingError(["adb"], "install android-platform-tools")
|
|
);
|
|
await expect(
|
|
buttonTool.execute(services, { udid: androidUdid, button: "home" })
|
|
).rejects.toBeInstanceOf(DependencyMissingError);
|
|
// Preflight fails closed: no keyevent is injected when adb is missing.
|
|
expect(injectAndroidKeycode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces an adb transport failure as a throw (no silent success — the #449 fix)", async () => {
|
|
// Moving off the fire-and-forget HID transport means a failed press must
|
|
// propagate, not resolve `{ pressed }` while nothing happened on-device.
|
|
vi.mocked(ensureDep).mockResolvedValueOnce(undefined);
|
|
vi.mocked(injectAndroidKeycode).mockRejectedValueOnce(new Error("adb: device offline"));
|
|
await expect(
|
|
buttonTool.execute(services, { udid: androidUdid, button: "home" })
|
|
).rejects.toThrow(/device offline/);
|
|
});
|
|
|
|
it("accepts every iOS-valid button and drives it over the sim-server as Down then Up (not adb)", async () => {
|
|
// Derive from the SOURCE set (mirroring the Android button test) so a future
|
|
// iOS button added to BUTTONS_BY_PLATFORM.ios is auto-covered here rather
|
|
// than silently skipped by a hardcoded list.
|
|
for (const button of BUTTONS_BY_PLATFORM.ios) {
|
|
vi.mocked(sendCommand).mockClear();
|
|
vi.mocked(injectAndroidKeycode).mockClear();
|
|
await expect(buttonTool.execute(services, { udid: iosUdid, button })).resolves.toEqual({
|
|
pressed: button,
|
|
});
|
|
// iOS presses go over the simulator-server HID transport as an ordered
|
|
// Down→Up pair. Assert the exact pair (not just `{ pressed }`) so dropping
|
|
// or reordering an event — or misrouting an iOS press into the Android adb
|
|
// branch — turns this red instead of passing on the result alone.
|
|
expect(vi.mocked(sendCommand).mock.calls.map((c) => c[1])).toEqual([
|
|
{ cmd: "button", direction: "Down", button },
|
|
{ cmd: "button", direction: "Up", button },
|
|
]);
|
|
expect(injectAndroidKeycode).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("button tool — service declaration", () => {
|
|
it("does not declare the simulator-server service for an Android target", () => {
|
|
// Android presses go over adb; declaring sim-server would needlessly resolve +
|
|
// spawn it (up to a 30s ready-wait) and could throw before the adb path runs.
|
|
expect(buttonTool.services({ udid: androidUdid, button: "back" })).toEqual({});
|
|
});
|
|
|
|
it("still declares the simulator-server service eagerly for an iOS target", () => {
|
|
expect(buttonTool.services({ udid: iosUdid, button: "home" })).toHaveProperty(
|
|
"simulatorServer"
|
|
);
|
|
});
|
|
});
|