diff --git a/packages/argent-installer/test/update.test.ts b/packages/argent-installer/test/update.test.ts index 062261a3..e2915a9e 100644 --- a/packages/argent-installer/test/update.test.ts +++ b/packages/argent-installer/test/update.test.ts @@ -4,6 +4,7 @@ import { detectPackageManager, globalInstallCommand, formatShellCommand, + isTempRunnerPath, } from "../src/utils.js"; import { PACKAGE_NAME, NPM_REGISTRY } from "../src/constants.js"; @@ -54,6 +55,33 @@ describe("update — constants are correct", () => { }); }); +describe("update — temp runner detection", () => { + // npx-cached argent shares the latest version, so without this filter the + // version compare would falsely match latest after the user uninstalled the + // global package via `npx @swmansion/argent uninstall`. + it("flags npx cache paths as transient", () => { + expect(isTempRunnerPath("/Users/me/.npm/_npx/abc123/node_modules/.bin/argent")).toBe(true); + }); + + it("flags pnpm dlx cache paths as transient", () => { + expect(isTempRunnerPath("/Users/me/.pnpm-store/dlx-1234/node_modules/.bin/argent")).toBe(true); + }); + + it("flags bun install cache paths as transient", () => { + expect(isTempRunnerPath("/Users/me/.bun/install/cache/argent")).toBe(true); + }); + + it("flags Windows dlx cache paths as transient", () => { + expect(isTempRunnerPath("C:\\Users\\me\\AppData\\Local\\dlx-abc\\argent.cmd")).toBe(true); + }); + + it("treats real global install paths as permanent", () => { + expect(isTempRunnerPath("/usr/local/bin/argent")).toBe(false); + expect(isTempRunnerPath("/opt/homebrew/bin/argent")).toBe(false); + expect(isTempRunnerPath("C:\\Users\\me\\AppData\\Roaming\\npm\\argent.cmd")).toBe(false); + }); +}); + describe("update — registry safety", () => { it("globalInstallCommand never includes --registry (relies on .npmrc scoped registry)", () => { for (const pm of ["npm", "yarn", "pnpm", "bun"] as const) { diff --git a/packages/argent-mcp/test/auto-screenshot.test.ts b/packages/argent-mcp/test/auto-screenshot.test.ts index 50f51082..c1df365c 100644 --- a/packages/argent-mcp/test/auto-screenshot.test.ts +++ b/packages/argent-mcp/test/auto-screenshot.test.ts @@ -180,18 +180,33 @@ describe("getAutoScreenshotDelayMs", () => { }); // --------------------------------------------------------------------------- -// AUTO_SCREENSHOT_TOOLS consistency +// shouldAutoScreenshot — unified tools trigger one screenshot regardless of platform // --------------------------------------------------------------------------- -describe("AUTO_SCREENSHOT_TOOLS and delay map consistency", () => { - it("every tool in the allow-list has a corresponding delay", () => { - for (const tool of AUTO_SCREENSHOT_TOOLS) { - expect(AUTO_SCREENSHOT_DELAY_MS_BY_TOOL).toHaveProperty(tool); - } +describe("shouldAutoScreenshot — unified surface", () => { + it("returns false for the screenshot tool itself (prevents recursion)", () => { + expect(shouldAutoScreenshot("screenshot")).toBe(false); + expect(shouldAutoScreenshot("mcp__argent__screenshot")).toBe(false); }); - it("every tool in the delay map is in the allow-list", () => { - for (const tool of Object.keys(AUTO_SCREENSHOT_DELAY_MS_BY_TOOL)) { - expect(AUTO_SCREENSHOT_TOOLS.has(tool)).toBe(true); + it("returns true for unified interaction tools", () => { + for (const t of [ + "gesture-tap", + "gesture-swipe", + "button", + "keyboard", + "rotate", + "launch-app", + "restart-app", + "open-url", + "describe", + "run-sequence", + ]) { + expect(shouldAutoScreenshot(t)).toBe(true); } }); + + it("normalizes MCP-prefixed names before looking up the allow-list", () => { + expect(shouldAutoScreenshot("mcp__argent__gesture-tap")).toBe(true); + expect(shouldAutoScreenshot("mcp__argent__launch-app")).toBe(true); + }); }); diff --git a/packages/tool-server/test/adb-hardening.test.ts b/packages/tool-server/test/adb-hardening.test.ts new file mode 100644 index 00000000..2cad20aa --- /dev/null +++ b/packages/tool-server/test/adb-hardening.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) callback(result, { stdout: "", stderr: "" }); + else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +// `runAdb` / `listAvds` now resolve adb / emulator to an absolute path before +// spawning, so a bare `cmd === "adb"` matcher would never fire on real hosts. +// Stub the resolver to return the bare name so existing test mocks keep +// working, regardless of whether the host has the SDK installed. +vi.mock("../src/utils/android-binary", () => ({ + resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name), + __resetAndroidBinaryCacheForTesting: () => {}, +})); + +import { listAndroidDevices, listAvds } from "../src/utils/adb"; + +beforeEach(() => { + execFileMock.mockReset(); +}); + +describe("readAvdName — modern property, legacy fallback (review #3)", () => { + /** + * Emulator release 30 (Android 11+) moved the AVD name from + * `ro.kernel.qemu.avd_name` to `ro.boot.qemu.avd_name`. Reading only the + * old property makes modern images report `avdName: null`, which in turn + * breaks `findSerialByAvdName` disambiguation when two emulators boot + * concurrently. + * + * The fix probes the new prop first and falls back to the old one. These + * tests pin both paths. + */ + + function mockAdbGetProps( + serial: string, + props: Partial<{ + "ro.product.model": string; + "ro.build.version.sdk": string; + "ro.boot.qemu.avd_name": string; + "ro.kernel.qemu.avd_name": string; + }> + ): void { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "adb" && args[0] === "devices" && args.length === 1) { + return { stdout: `List of devices attached\n${serial}\tdevice\n`, stderr: "" }; + } + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + const shell = args[3] ?? ""; + for (const [prop, value] of Object.entries(props)) { + if (shell === `getprop ${prop}`) return { stdout: `${value}\n`, stderr: "" }; + } + return { stdout: "\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + } + + it("reads ro.boot.qemu.avd_name on modern images (Android 11+)", async () => { + mockAdbGetProps("emulator-5554", { + "ro.product.model": "sdk_gphone64", + "ro.build.version.sdk": "34", + "ro.boot.qemu.avd_name": "Pixel_7_API_34", + "ro.kernel.qemu.avd_name": "", + }); + + const devices = await listAndroidDevices(); + expect(devices).toHaveLength(1); + expect(devices[0]!.avdName).toBe("Pixel_7_API_34"); + }); + + it("falls back to ro.kernel.qemu.avd_name on legacy images", async () => { + mockAdbGetProps("emulator-5554", { + "ro.product.model": "sdk_gphone", + "ro.build.version.sdk": "29", + "ro.boot.qemu.avd_name": "", + "ro.kernel.qemu.avd_name": "Pixel_3a_API_29", + }); + + const devices = await listAndroidDevices(); + expect(devices[0]!.avdName).toBe("Pixel_3a_API_29"); + }); + + it("prefers the modern property when both are present (some images double-set)", async () => { + mockAdbGetProps("emulator-5554", { + "ro.product.model": "sdk_gphone64", + "ro.build.version.sdk": "34", + "ro.boot.qemu.avd_name": "Pixel_7_API_34", + "ro.kernel.qemu.avd_name": "Pixel_7_API_34_stale", + }); + + const devices = await listAndroidDevices(); + expect(devices[0]!.avdName).toBe("Pixel_7_API_34"); + }); + + it("returns null when neither property is set (physical device)", async () => { + mockAdbGetProps("R5CT12345678", { + "ro.product.model": "SM-G990B", + "ro.build.version.sdk": "33", + }); + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "adb" && args[0] === "devices") { + return { stdout: `List of devices attached\nR5CT12345678\tdevice\n`, stderr: "" }; + } + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + const shell = args[3] ?? ""; + if (shell === "getprop ro.product.model") return { stdout: "SM-G990B\n", stderr: "" }; + if (shell === "getprop ro.build.version.sdk") return { stdout: "33\n", stderr: "" }; + return { stdout: "\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const devices = await listAndroidDevices(); + expect(devices[0]!.avdName).toBeNull(); + }); +}); + +describe("listAvds noise filter (review #9)", () => { + /** + * Old filter was prefix-only — any AVD name starting with INFO/HAX was + * silently dropped. Real `emulator -list-avds` noise is diagnostic + * header/footer lines that contain whitespace or colons (e.g. + * `INFO | Android emulator version ...`), while AVD names are + * identifier-only. The new filter accepts identifier-shaped lines only. + */ + + it("accepts an AVD name that happens to start with HAX (e.g. HAX-Pixel-6)", async () => { + execFileMock.mockImplementation((cmd: string) => { + if (cmd === "emulator") { + return { stdout: "HAX-Pixel-6\nINFO_BuildBot_Pixel7\nPixel_7_API_34\n", stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + const avds = await listAvds(); + expect(avds.map((a) => a.name)).toEqual([ + "HAX-Pixel-6", + "INFO_BuildBot_Pixel7", + "Pixel_7_API_34", + ]); + }); + + it("filters out genuine noise lines with whitespace / pipe characters", async () => { + // Real emulator output on at least some installs prints a log-format header. + execFileMock.mockImplementation((cmd: string) => { + if (cmd === "emulator") { + return { + stdout: [ + "INFO | Android emulator version 33.1.11.0", + "HAX is working and emulator runs in fast virt mode.", + "Pixel_7_API_34", + "Pixel_3a_API_29", + "", + ].join("\n"), + stderr: "", + }; + } + return { stdout: "", stderr: "" }; + }); + const avds = await listAvds(); + expect(avds.map((a) => a.name)).toEqual(["Pixel_7_API_34", "Pixel_3a_API_29"]); + }); +}); diff --git a/packages/tool-server/test/adb-terminal-error-format.test.ts b/packages/tool-server/test/adb-terminal-error-format.test.ts new file mode 100644 index 00000000..84482682 --- /dev/null +++ b/packages/tool-server/test/adb-terminal-error-format.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFile: ( + cmd: string, + args: readonly string[], + opts: unknown, + cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === "function" ? opts : cb!; + const options = typeof opts === "function" ? undefined : opts; + const result = execFileMock(cmd, args, options); + if (result instanceof Error) { + // Mirror execFile's actual rejection contract: stderr/stdout are + // attached to the error object so describeAdbFailure can read them. + const e = result as Error & { stderr?: string; stdout?: string }; + callback(e, { stdout: e.stdout ?? "", stderr: e.stderr ?? "" }); + } else callback(null, result ?? { stdout: "", stderr: "" }); + }, + }; +}); + +// `runAdb` resolves adb to an absolute path before spawning. Stub the +// resolver to return the bare name so existing `cmd === "adb"` mocks fire. +vi.mock("../src/utils/android-binary", () => ({ + resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name), + __resetAndroidBinaryCacheForTesting: () => {}, +})); + +import { waitForBootCompleted } from "../src/utils/adb"; + +beforeEach(() => { + execFileMock.mockReset(); +}); + +/** + * `isTerminalAdbError` checks for the literal substring "device not found", + * but adb's actual stderr is `error: device 'emulator-5554' not found` — + * the serial appears between "device" and "not found", so the substring + * match never fires. Result: when a device drops off PATH mid-boot, + * `waitForBootCompleted` keeps spinning until the full timeoutMs elapses + * (default 120 s) instead of failing fast with the actionable error. + * + * Expected: the function should detect the terminal state and throw on + * the first failed poll (well before timeoutMs). + */ +describe("isTerminalAdbError matches adb's real `device 'X' not found` format", () => { + it("waitForBootCompleted should fail fast when adb says \"device 'X' not found\"", async () => { + execFileMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") { + const err = new Error("Command failed") as Error & { stderr?: string }; + err.stderr = "error: device 'emulator-5554' not found"; + return err; + } + return new Error("unexpected call"); + }); + + const start = Date.now(); + // Use a small budget so the test doesn't take 2 minutes; the bug + // produces a full-timeoutMs hang regardless of size. + await expect(waitForBootCompleted("emulator-5554", 4_000)).rejects.toThrow( + /terminal state|device.*not found/i + ); + const elapsed = Date.now() - start; + // Fail-fast path: throw fires after the first failed poll (< 1 s). + // Bug path: loop spins until the deadline (~timeoutMs). + // Anything ≥ 3 s on the 4 s budget proves the bug. + expect(elapsed).toBeLessThan(2_500); + }, 8_000); +}); diff --git a/packages/tool-server/test/android-adb.test.ts b/packages/tool-server/test/android-adb.test.ts new file mode 100644 index 00000000..18138529 --- /dev/null +++ b/packages/tool-server/test/android-adb.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { parseAdbDevices } from "../src/utils/adb"; + +describe("parseAdbDevices", () => { + it("parses a typical `adb devices` output", () => { + const stdout = [ + "List of devices attached", + "emulator-5554\tdevice", + "R5CT12345678\tdevice", + "", + ].join("\n"); + expect(parseAdbDevices(stdout)).toEqual([ + { serial: "emulator-5554", state: "device" }, + { serial: "R5CT12345678", state: "device" }, + ]); + }); + + it("includes offline and unauthorized devices with their state", () => { + const stdout = ["List of devices attached", "emulator-5554\toffline", "abc\tunauthorized"].join( + "\n" + ); + expect(parseAdbDevices(stdout)).toEqual([ + { serial: "emulator-5554", state: "offline" }, + { serial: "abc", state: "unauthorized" }, + ]); + }); + + it("ignores blank lines and the header only", () => { + expect(parseAdbDevices("List of devices attached\n\n")).toEqual([]); + }); + + it("tolerates `-l` suffix metadata after state", () => { + const stdout = [ + "List of devices attached", + "emulator-5554\tdevice product:sdk_gphone64_arm64 model:sdk_gphone64_arm64", + ].join("\n"); + expect(parseAdbDevices(stdout)).toEqual([{ serial: "emulator-5554", state: "device" }]); + }); + + it("skips the daemon-startup banner adb prints when its background server is cold", () => { + // Real output when the adb server isn't running yet — without a guard, + // the loose `\S+ \s+ \S+` regex parses these as devices and the boot loop + // adopts a phantom serial. + const stdout = [ + "* daemon not running; starting now at tcp:5037 *", + "* daemon started successfully *", + "List of devices attached", + "emulator-5554\tdevice", + ].join("\n"); + expect(parseAdbDevices(stdout)).toEqual([{ serial: "emulator-5554", state: "device" }]); + }); + + it("ignores lines whose state is not a known adb state", () => { + // Defensive: anything that isn't in the canonical adb state set must not + // become a phantom device. Catches future adb versions adding garbage + // fields and protects against a subtly-malformed banner the * filter misses. + const stdout = ["List of devices attached", "emulator-5554\tdevice", "junk\tnotastate"].join( + "\n" + ); + expect(parseAdbDevices(stdout)).toEqual([{ serial: "emulator-5554", state: "device" }]); + }); +}); diff --git a/packages/tool-server/test/android-binary.test.ts b/packages/tool-server/test/android-binary.test.ts new file mode 100644 index 00000000..2a1a06e3 --- /dev/null +++ b/packages/tool-server/test/android-binary.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile, chmod } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + __resetAndroidBinaryCacheForTesting, + resolveAndroidBinary, +} from "../src/utils/android-binary"; +import { + __resetDepCacheForTests, + ensureDep, + DependencyMissingError, +} from "../src/utils/check-deps"; + +// Snapshot the env vars we mutate so a failing assertion can't leak state into +// the next test (or the surrounding process: vitest reuses the worker for +// other suites and a stale ANDROID_HOME would silently flip their behavior). +const ENV_KEYS = ["PATH", "ANDROID_HOME", "ANDROID_SDK_ROOT"] as const; +const originalEnv: Record = {}; + +async function fakeSdk(root: string, name: "adb" | "emulator"): Promise { + const subdir = name === "adb" ? "platform-tools" : "emulator"; + const dir = join(root, subdir); + await mkdir(dir, { recursive: true }); + const path = join(dir, name); + // Minimal executable shim — the resolver only checks X_OK + path; spawning + // is exercised separately in adb.ts integration tests. + await writeFile(path, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await chmod(path, 0o755); + return path; +} + +describe("resolveAndroidBinary", () => { + let tmpRoot: string; + + beforeEach(async () => { + for (const k of ENV_KEYS) originalEnv[k] = process.env[k]; + __resetAndroidBinaryCacheForTesting(); + __resetDepCacheForTests(); + tmpRoot = await mkdtemp(join(tmpdir(), "argent-android-binary-")); + }); + + afterEach(async () => { + for (const k of ENV_KEYS) { + if (originalEnv[k] === undefined) delete process.env[k]; + else process.env[k] = originalEnv[k]; + } + await rm(tmpRoot, { recursive: true, force: true }); + }); + + it("finds emulator under $ANDROID_HOME when not on PATH", async () => { + const sdk = join(tmpRoot, "sdk"); + const expected = await fakeSdk(sdk, "emulator"); + // Strip PATH down to OS basics so the test doesn't accidentally find a + // real `emulator` binary on the host running the suite (CI shouldn't have + // one but a developer's macOS easily can). + process.env.PATH = "/usr/bin:/bin"; + process.env.ANDROID_HOME = sdk; + delete process.env.ANDROID_SDK_ROOT; + + const path = await resolveAndroidBinary("emulator"); + expect(path).toBe(expected); + }); + + it("finds adb under $ANDROID_HOME/platform-tools when not on PATH", async () => { + const sdk = join(tmpRoot, "sdk"); + const expected = await fakeSdk(sdk, "adb"); + process.env.PATH = "/usr/bin:/bin"; + process.env.ANDROID_HOME = sdk; + delete process.env.ANDROID_SDK_ROOT; + + const path = await resolveAndroidBinary("adb"); + expect(path).toBe(expected); + }); + + it("falls back to $ANDROID_SDK_ROOT when $ANDROID_HOME is unset", async () => { + const sdk = join(tmpRoot, "sdk-root"); + const expected = await fakeSdk(sdk, "emulator"); + process.env.PATH = "/usr/bin:/bin"; + delete process.env.ANDROID_HOME; + process.env.ANDROID_SDK_ROOT = sdk; + + const path = await resolveAndroidBinary("emulator"); + expect(path).toBe(expected); + }); + + it("prefers PATH over $ANDROID_HOME when both resolve", async () => { + // PATH-installed copy + const pathBinDir = join(tmpRoot, "pathbin"); + await mkdir(pathBinDir, { recursive: true }); + const pathCopy = join(pathBinDir, "emulator"); + await writeFile(pathCopy, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await chmod(pathCopy, 0o755); + // $ANDROID_HOME-installed copy + const sdk = join(tmpRoot, "sdk"); + await fakeSdk(sdk, "emulator"); + + process.env.PATH = `${pathBinDir}:/usr/bin:/bin`; + process.env.ANDROID_HOME = sdk; + delete process.env.ANDROID_SDK_ROOT; + + const path = await resolveAndroidBinary("emulator"); + expect(path).toBe(pathCopy); + }); + + it("returns null when neither PATH nor SDK roots resolve", async () => { + process.env.PATH = "/usr/bin:/bin"; + delete process.env.ANDROID_HOME; + delete process.env.ANDROID_SDK_ROOT; + + const path = await resolveAndroidBinary("emulator"); + expect(path).toBeNull(); + }); + + it("ignores a non-executable file at the canonical SDK path", async () => { + const sdk = join(tmpRoot, "sdk"); + const dir = join(sdk, "emulator"); + await mkdir(dir, { recursive: true }); + // Mode 0o644 — present but not executable, simulating a corrupted install. + await writeFile(join(dir, "emulator"), "stub", { mode: 0o644 }); + await chmod(join(dir, "emulator"), 0o644); + + process.env.PATH = "/usr/bin:/bin"; + process.env.ANDROID_HOME = sdk; + delete process.env.ANDROID_SDK_ROOT; + + const path = await resolveAndroidBinary("emulator"); + // Resolver should refuse the non-executable candidate. With no other + // root configured, that means null. + expect(path).toBeNull(); + }); +}); + +describe("ensureDep('emulator')", () => { + let tmpRoot: string; + + beforeEach(async () => { + for (const k of ENV_KEYS) originalEnv[k] = process.env[k]; + __resetAndroidBinaryCacheForTesting(); + __resetDepCacheForTests(); + tmpRoot = await mkdtemp(join(tmpdir(), "argent-ensure-dep-")); + }); + + afterEach(async () => { + for (const k of ENV_KEYS) { + if (originalEnv[k] === undefined) delete process.env[k]; + else process.env[k] = originalEnv[k]; + } + await rm(tmpRoot, { recursive: true, force: true }); + }); + + it("passes when emulator is resolvable via $ANDROID_HOME alone", async () => { + const sdk = join(tmpRoot, "sdk"); + await fakeSdk(sdk, "emulator"); + process.env.PATH = "/usr/bin:/bin"; + process.env.ANDROID_HOME = sdk; + delete process.env.ANDROID_SDK_ROOT; + + await expect(ensureDep("emulator")).resolves.toBeUndefined(); + }); + + it("throws DependencyMissingError with install hint when neither resolves", async () => { + process.env.PATH = "/usr/bin:/bin"; + delete process.env.ANDROID_HOME; + delete process.env.ANDROID_SDK_ROOT; + + await expect(ensureDep("emulator")).rejects.toBeInstanceOf(DependencyMissingError); + try { + await ensureDep("emulator"); + } catch (err) { + // The hint must guide the user to fix the actual problem (set + // ANDROID_HOME) rather than just the prior PATH-only message. + expect((err as Error).message).toMatch(/ANDROID_HOME/); + expect((err as Error).message).toMatch(/emulator/); + } + }); +}); diff --git a/packages/tool-server/test/android-describe-screen.test.ts b/packages/tool-server/test/android-describe-screen.test.ts new file mode 100644 index 00000000..9c42e143 --- /dev/null +++ b/packages/tool-server/test/android-describe-screen.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { parseDescribeResult } from "../src/tools/describe/contract"; +import { + deriveUiAutomatorRole, + parseUiAutomatorBounds, + parseUiAutomatorDump, +} from "../src/utils/uiautomator-parser"; + +describe("parseUiAutomatorBounds", () => { + it("parses [x1,y1][x2,y2]", () => { + expect(parseUiAutomatorBounds("[0,0][1080,1920]")).toEqual({ + x: 0, + y: 0, + w: 1080, + h: 1920, + }); + }); + + it("handles non-zero origins", () => { + expect(parseUiAutomatorBounds("[100,200][400,800]")).toEqual({ + x: 100, + y: 200, + w: 300, + h: 600, + }); + }); + + it("returns null for unparseable input", () => { + expect(parseUiAutomatorBounds("garbage")).toBeNull(); + }); +}); + +describe("deriveUiAutomatorRole", () => { + const cases: Array<[string, string]> = [ + ["android.widget.Button", "Button"], + ["android.widget.ImageButton", "Button"], + ["android.widget.EditText", "TextField"], + ["android.widget.TextView", "StaticText"], + ["android.widget.ImageView", "Image"], + ["android.widget.Switch", "Switch"], + ["android.widget.CheckBox", "CheckBox"], + ["android.widget.RadioButton", "RadioButton"], + ["androidx.recyclerview.widget.RecyclerView", "ScrollView"], + ["android.webkit.WebView", "WebView"], + ["", "View"], + ["com.example.CustomWidget", "CustomWidget"], + ]; + for (const [input, expected] of cases) { + it(`maps ${input || "(empty)"} → ${expected}`, () => { + expect(deriveUiAutomatorRole(input)).toBe(expected); + }); + } +}); + +describe("parseUiAutomatorDump", () => { + const sampleXml = ` + + + + + + +`; + + // The v2 trim flattens layout-only wrappers (FrameLayout with no own + // info) so the inner widgets surface directly under the Screen root — + // tree.children = [TextView, EditText, Button]. The TextView/EditText/Button + // path now lives at tree.children[0], not tree.children[0].children[0]. + + it("returns a synthetic Screen root with full-screen frame", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + expect(tree.role).toBe("Screen"); + expect(tree.frame).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + // FrameLayout passthrough → 3 leaf widgets surface as Screen children. + expect(tree.children).toHaveLength(3); + }); + + it("normalizes pixel bounds to 0–1 using the provided screen size", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + const title = tree.children[0]!; + expect(title.label).toBe("Sign in"); + expect(title.frame.x).toBeCloseTo(100 / 1080, 3); + expect(title.frame.y).toBeCloseTo(200 / 1920, 3); + expect(title.frame.width).toBeCloseTo((980 - 100) / 1080, 3); + expect(title.frame.height).toBeCloseTo((280 - 200) / 1920, 3); + }); + + it("maps class → role and populates label/identifier/value appropriately", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + const [title, email, submit] = tree.children as [typeof tree, typeof tree, typeof tree]; + + expect(title.role).toBe("StaticText"); + expect(title.label).toBe("Sign in"); + expect(title.identifier).toBe("com.example.app:id/title"); + + expect(email.role).toBe("TextField"); + expect(email.label).toBe("Email address"); // content-desc wins over empty text + expect(email.value).toBeUndefined(); + expect(email.clickable).toBe(true); // v2 surfaces interactivity flags + + expect(submit.role).toBe("Button"); + expect(submit.label).toBe("Submit"); // text is used when content-desc is empty + expect(submit.clickable).toBe(true); + }); + + it("produces output matching the shared DescribeNode schema", () => { + const tree = parseUiAutomatorDump(sampleXml, 1080, 1920); + expect(() => parseDescribeResult(tree)).not.toThrow(); + }); + + it("strips the trailing `UI hierchary dumped to:` status line from the raw dump", () => { + const withTrailer = sampleXml + "\nUI hierchary dumped to: /dev/tty\n"; + const tree = parseUiAutomatorDump(withTrailer, 1080, 1920); + // Same flattened shape as the trim-free run. + expect(tree.children).toHaveLength(3); + }); + + it("drops every node when the screen size is zero (defensive)", () => { + // The v2 trim treats screen size 0×0 as "nothing on screen", so every + // node fails the visibility check and the tree empties out. Previous + // behaviour was to surface a zero-area frame; the trim's invariant is + // stronger and easier to reason about. + const tree = parseUiAutomatorDump(sampleXml, 0, 0); + expect(tree.children).toHaveLength(0); + }); +}); diff --git a/packages/tool-server/test/android-injection-hardening.test.ts b/packages/tool-server/test/android-injection-hardening.test.ts new file mode 100644 index 00000000..13bdb23c --- /dev/null +++ b/packages/tool-server/test/android-injection-hardening.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { launchAppTool } from "../src/tools/launch-app"; +import { restartAppTool } from "../src/tools/restart-app"; +import { openUrlTool } from "../src/tools/open-url"; +import { reinstallAppTool } from "../src/tools/reinstall-app"; +import { createDescribeTool } from "../src/tools/describe"; +import { Registry } from "@argent/registry"; + +/** + * Regressions for the command-injection review finding (#1) and the + * empty-udid routing finding (#7). + * + * The attack surface: every Android branch interpolates `bundleId` (and + * sometimes `activity`) directly into an `adb shell "