mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
fcf0ede624
## What Adds a `settings-permissions` MCP tool that edits an app's runtime permissions **out-of-band**, without navigating the system Settings UI. It's a test-setup / out-of-band tool — the default way to change a permission is still through the app (in-app toggle or the app-triggered dialog). Reach for this only when the app can't get there: - **pre-authorize** (`grant`) or **deny** a permission before the app asks, so no dialog interrupts the flow - **re-enable** one the user already denied (iOS never re-prompts once denied) - **reset** it so the first-run dialog reappears on next launch Supported permissions: camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders. ## How **iOS simulator** — `xcrun simctl privacy grant|revoke|reset <service> [bundleId]`. - `notifications` has no simctl service → surfaced as an unsupported error. - An "invalid service" failure (e.g. `camera` on an Xcode that doesn't model it) is wrapped as unsupported with a hint to list supported services. - A shutdown simulator error carries a `boot-device` hint. **Android** — `pm grant` / `pm revoke` (plus `clear-permission-flags` on reset) over adb. - One abstract permission fans out to the concrete `android.permission.*` set (fine/coarse location, per-media reads); `location-always` grant also grants foreground location since background alone is unusable. - A `pm path` preflight rejects an uninstalled package (`pm grant`/`revoke` exit 0 for a missing package, which would otherwise report a false success). A transport-level adb failure is rethrown rather than mislabeled as "not installed" (`isTerminalAdbError` is now exported for this). - Partial results land in `skipped`; the action fails only when pm rejects **every** mapped permission. ## Supporting changes - New failure codes: `ANDROID_SETTINGS_PERMISSION_FAILED`, `IOS_SETTINGS_PERMISSION_FAILED`, `SETTINGS_PERMISSION_UNSUPPORTED`. - New `argent-settings-permissions` skill, plus cross-references from `argent-device-interact`, `argent-test-ui-flow`, and the argent rule (with a strong "prefer the app dialog over this tool" steer). - `bundleId` is regex-validated (same shell-injection guard as launch-app/restart-app) and schema-required for grant/deny. ## Testing `vitest` — **37/37 pass** (schema validation incl. shell-injection payloads, iOS branch, Android branch incl. partial/total pm failures and the preflight). `tsc --noEmit` clean for tool-server + registry + tests; prettier clean. --------- Co-authored-by: Filip131311 <contact@filipkaminski.com>
988 lines
43 KiB
TypeScript
988 lines
43 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const execFileMock = vi.fn();
|
|
vi.mock("node:child_process", async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
return { ...actual, execFile: (...args: unknown[]) => execFileMock(...args) };
|
|
});
|
|
|
|
vi.mock("../src/utils/adb", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/utils/adb")>();
|
|
return {
|
|
adbShell: vi.fn(async () => ""),
|
|
shellQuote: actual.shellQuote,
|
|
// Real classifier — the Android handler uses it to tell a transport/timeout
|
|
// failure (propagate) from a pm rejection (fold into `skipped`).
|
|
isTerminalAdbError: actual.isTerminalAdbError,
|
|
};
|
|
});
|
|
|
|
import type { DeviceInfo } from "@argent/registry";
|
|
import {
|
|
FAILURE_CODES,
|
|
FailureError,
|
|
getFailureSignal,
|
|
zodObjectToJsonSchema,
|
|
} from "@argent/registry";
|
|
import { settingsPermissionsTool } from "../src/tools/settings-permissions";
|
|
import { iosImpl } from "../src/tools/settings-permissions/platforms/ios";
|
|
import { androidImpl } from "../src/tools/settings-permissions/platforms/android";
|
|
import type { SettingsPermissionsParams } from "../src/tools/settings-permissions/types";
|
|
import { adbShell } from "../src/utils/adb";
|
|
import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps";
|
|
|
|
const mockAdbShell = vi.mocked(adbShell);
|
|
|
|
const IOS_UDID = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
|
|
const ANDROID_SERIAL = "emulator-5554";
|
|
|
|
const iosDevice: DeviceInfo = { id: IOS_UDID, platform: "ios", kind: "simulator" };
|
|
const androidDevice: DeviceInfo = { id: ANDROID_SERIAL, platform: "android", kind: "emulator" };
|
|
|
|
// FailureError attaches its FailureSignal under a non-enumerable symbol, so
|
|
// toMatchObject can't see it — assert through the public accessor instead.
|
|
// The `typeof code === "string"` guard is load-bearing: if a FAILURE_CODES
|
|
// member ever resolves to `undefined` (e.g. vitest loading a stale @argent/
|
|
// registry dist that predates a new code), the matcher would otherwise degrade
|
|
// to `undefined === undefined` and pass for *any* rejection — masking real
|
|
// failures. The string check turns that into an always-false matcher so the
|
|
// affected assertions fail loudly instead. (See the FAILURE_CODES type test.)
|
|
function failsWith(code: string): (err: unknown) => boolean {
|
|
return (err) => typeof code === "string" && getFailureSignal(err)?.error_code === code;
|
|
}
|
|
|
|
// The promisified execFile mock: resolve = success, reject-style = call with error.
|
|
// Resolves with a `{ stdout, stderr }` object so a consumer that reads `.stdout`
|
|
// (sim-remote's `run`, used by the ios-remote branch) sees the same shape the
|
|
// real execFile custom-promisify yields; handlers that ignore the return
|
|
// (local xcrun path) are unaffected.
|
|
function execFileSucceeds(): void {
|
|
execFileMock.mockImplementation(
|
|
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown, out?: unknown) => void) => {
|
|
cb(null, { stdout: "", stderr: "" });
|
|
}
|
|
);
|
|
}
|
|
|
|
function execFileFails(message: string): void {
|
|
execFileMock.mockImplementation(
|
|
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => {
|
|
cb(Object.assign(new Error(message), { code: 1, stderr: message }));
|
|
}
|
|
);
|
|
}
|
|
|
|
// Default adb behavior: the `pm list packages` existence preflight finds the
|
|
// package (it prints a `package:<name>` line and exits 0), and every mutating pm
|
|
// command succeeds silently (pm's real success shape).
|
|
function adbDefaults(overrides?: (cmd: string) => string | Promise<string> | undefined): void {
|
|
mockAdbShell.mockImplementation(async (_serial, cmd) => {
|
|
const overridden = overrides?.(cmd);
|
|
if (overridden !== undefined) return overridden;
|
|
if (cmd.startsWith("pm list packages")) return "package:com.example.app";
|
|
return "";
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
mockAdbShell.mockReset();
|
|
adbDefaults();
|
|
});
|
|
|
|
describe("settings-permissions failure codes are defined", () => {
|
|
// Guards the whole suite: `failsWith` compares against these constants, and a
|
|
// stale @argent/registry dist that predates them would resolve them to
|
|
// `undefined`, silently defanging every `failsWith` assertion. Assert they are
|
|
// real strings so a missing code fails here loudly instead of hiding elsewhere.
|
|
it("resolves the three settings-permissions codes to strings", () => {
|
|
for (const code of [
|
|
"SETTINGS_PERMISSION_UNSUPPORTED",
|
|
"IOS_SETTINGS_PERMISSION_FAILED",
|
|
"ANDROID_SETTINGS_PERMISSION_FAILED",
|
|
] as const) {
|
|
expect(typeof FAILURE_CODES[code], code).toBe("string");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("settings-permissions schema", () => {
|
|
const schema = settingsPermissionsTool.zodSchema!;
|
|
|
|
it("accepts grant with a bundleId", () => {
|
|
const parsed = schema.safeParse({
|
|
udid: IOS_UDID,
|
|
action: "grant",
|
|
permission: "camera",
|
|
bundleId: "com.example.app",
|
|
});
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
|
|
it("rejects grant without a bundleId", () => {
|
|
const parsed = schema.safeParse({ udid: IOS_UDID, action: "grant", permission: "camera" });
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it("rejects deny without a bundleId", () => {
|
|
const parsed = schema.safeParse({ udid: IOS_UDID, action: "deny", permission: "photos" });
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it("rejects reset without a bundleId (bundleId is required for every action)", () => {
|
|
// Device-wide reset is gone: simctl's no-bundleId reset silently leaves
|
|
// existing per-app grants intact on recent iOS, so the tool is per-app only.
|
|
const parsed = schema.safeParse({ udid: IOS_UDID, action: "reset", permission: "location" });
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it("rejects unknown permissions and actions", () => {
|
|
expect(
|
|
schema.safeParse({
|
|
udid: IOS_UDID,
|
|
action: "grant",
|
|
permission: "bluetooth",
|
|
bundleId: "com.example.app",
|
|
}).success
|
|
).toBe(false);
|
|
expect(
|
|
schema.safeParse({
|
|
udid: IOS_UDID,
|
|
action: "revoke",
|
|
permission: "camera",
|
|
bundleId: "com.example.app",
|
|
}).success
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects an empty udid", () => {
|
|
const parsed = schema.safeParse({
|
|
udid: "",
|
|
action: "grant",
|
|
permission: "camera",
|
|
bundleId: "com.example.app",
|
|
});
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
// Same attack surface as launch-app/restart-app: bundleId is interpolated
|
|
// into an `adb shell` string, so shell metacharacters must never validate.
|
|
const injectionPayloads = [
|
|
"com.foo;rm -rf /sdcard",
|
|
"com.foo`touch /sdcard/owned`",
|
|
"com.foo$(touch /sdcard/owned)",
|
|
"com.foo && reboot",
|
|
"com.foo\nmalicious",
|
|
"com.foo'; id; echo '",
|
|
"--user",
|
|
"-X",
|
|
];
|
|
for (const payload of injectionPayloads) {
|
|
it(`rejects bundleId with shell metachars: ${JSON.stringify(payload)}`, () => {
|
|
const parsed = schema.safeParse({
|
|
udid: ANDROID_SERIAL,
|
|
action: "grant",
|
|
permission: "camera",
|
|
bundleId: payload,
|
|
});
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
}
|
|
|
|
it("derives a sane MCP JSON schema with bundleId required for every action", () => {
|
|
// bundleId is a plain required field now (no superRefine), so it must appear
|
|
// in the JSON schema's `required` list — pin the derivation so a zod upgrade
|
|
// can't silently drop it and let a bundleId-less call reach the handler.
|
|
const json = zodObjectToJsonSchema(schema) as {
|
|
required?: string[];
|
|
properties?: Record<string, { pattern?: string; enum?: string[] }>;
|
|
};
|
|
expect(json.required).toEqual(["udid", "action", "permission", "bundleId"]);
|
|
expect(json.properties?.bundleId?.pattern).toBe("^[A-Za-z_][A-Za-z0-9._-]*$");
|
|
expect(json.properties?.action?.enum).toEqual(["grant", "deny", "reset"]);
|
|
expect(json.properties?.permission?.enum).toHaveLength(11);
|
|
});
|
|
});
|
|
|
|
describe("settings-permissions iOS branch", () => {
|
|
function params(overrides: Partial<SettingsPermissionsParams>): SettingsPermissionsParams {
|
|
return {
|
|
udid: IOS_UDID,
|
|
action: "grant",
|
|
permission: "microphone",
|
|
bundleId: "com.example.app",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("grant runs `simctl privacy <udid> grant <service> <bundleId>`", async () => {
|
|
execFileSucceeds();
|
|
const result = await iosImpl.handler({}, params({}), iosDevice);
|
|
expect(execFileMock).toHaveBeenCalledTimes(1);
|
|
const [cmd, args] = execFileMock.mock.calls[0]!;
|
|
expect(cmd).toBe("xcrun");
|
|
expect(args).toEqual(["simctl", "privacy", IOS_UDID, "grant", "microphone", "com.example.app"]);
|
|
expect(result).toEqual({
|
|
action: "grant",
|
|
permission: "microphone",
|
|
bundleId: "com.example.app",
|
|
applied: ["microphone"],
|
|
});
|
|
});
|
|
|
|
it("deny maps to simctl's `revoke`", async () => {
|
|
execFileSucceeds();
|
|
await iosImpl.handler({}, params({ action: "deny", permission: "photos" }), iosDevice);
|
|
const [, args] = execFileMock.mock.calls[0]!;
|
|
expect(args).toEqual(["simctl", "privacy", IOS_UDID, "revoke", "photos", "com.example.app"]);
|
|
});
|
|
|
|
it("reset is per-app: `simctl privacy <udid> reset <service> <bundleId>`", async () => {
|
|
// A device-wide reset (no bundleId) is intentionally not supported — it is a
|
|
// no-op for existing per-app grants on recent iOS, so reset always targets
|
|
// the one app and echoes its bundleId back.
|
|
execFileSucceeds();
|
|
const result = await iosImpl.handler(
|
|
{},
|
|
params({ action: "reset", permission: "location-always" }),
|
|
iosDevice
|
|
);
|
|
const [, args] = execFileMock.mock.calls[0]!;
|
|
expect(args).toEqual([
|
|
"simctl",
|
|
"privacy",
|
|
IOS_UDID,
|
|
"reset",
|
|
"location-always",
|
|
"com.example.app",
|
|
]);
|
|
expect(result.bundleId).toBe("com.example.app");
|
|
});
|
|
|
|
it("notifications is rejected as unsupported without calling simctl", async () => {
|
|
await expect(
|
|
iosImpl.handler({}, params({ permission: "notifications" }), iosDevice)
|
|
).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED));
|
|
expect(execFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a camera failure surfaces IOS_SETTINGS_PERMISSION_FAILED with a list-services hint", async () => {
|
|
// simctl rejects a service it doesn't model with a generic CoreSimulator
|
|
// NSError, indistinguishable from any other failure — so instead of parsing
|
|
// its wording, camera (the one service simulators may lack) always carries a
|
|
// hint to list the supported services.
|
|
execFileFails(
|
|
"An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=1):\n" +
|
|
"Simulator device failed to complete the requested operation.\nOperation not permitted\n" +
|
|
"Underlying error (domain=NSPOSIXErrorDomain, code=1):\n\tFailed to set access"
|
|
);
|
|
const rejection = expect(
|
|
iosImpl.handler({}, params({ permission: "camera" }), iosDevice)
|
|
).rejects;
|
|
await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED));
|
|
await rejection.toThrow(/run `xcrun simctl privacy` to list the services it supports/);
|
|
});
|
|
|
|
it("a non-camera failure does not get the camera hint", async () => {
|
|
execFileFails("Simulator device failed to complete the requested operation.");
|
|
await expect(
|
|
iosImpl.handler({}, params({ permission: "microphone" }), iosDevice)
|
|
).rejects.not.toThrow(/list the services it supports/);
|
|
});
|
|
|
|
it("other simctl failures surface as IOS_SETTINGS_PERMISSION_FAILED", async () => {
|
|
execFileFails("Invalid device: nope");
|
|
await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toSatisfy(
|
|
failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED)
|
|
);
|
|
});
|
|
|
|
it("a shutdown-simulator failure carries a boot-device hint", async () => {
|
|
execFileFails(
|
|
"An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown"
|
|
);
|
|
await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toThrow(
|
|
/must be booted first — use boot-device/
|
|
);
|
|
});
|
|
|
|
it("a camera failure on a shutdown simulator gets the boot-device hint, not the camera hint", async () => {
|
|
// cameraHint is gated on `!shutdownHint`, so a shutdown error must win even
|
|
// for camera — otherwise the agent is told to list services when the real
|
|
// fix is to boot the device. Pins that guard (the camera-hint and shutdown
|
|
// tests above never pair the two).
|
|
execFileFails(
|
|
"An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown"
|
|
);
|
|
const rejection = expect(
|
|
iosImpl.handler({}, params({ permission: "camera" }), iosDevice)
|
|
).rejects;
|
|
await rejection.toThrow(/must be booted first — use boot-device/);
|
|
await rejection.not.toThrow(/list the services it supports/);
|
|
});
|
|
|
|
it("maps each supported permission to its simctl privacy service (identity)", async () => {
|
|
// Lock IOS_SERVICES: microphone/photos/location-always are asserted above,
|
|
// this pins the rest so an accidental empty/typo (which would surface a false
|
|
// "unsupported") is caught — notably `reminders`, the sole iOS-only service.
|
|
execFileSucceeds();
|
|
const cases: Array<[SettingsPermissionsParams["permission"], string]> = [
|
|
["contacts", "contacts"],
|
|
["calendar", "calendar"],
|
|
["location", "location"],
|
|
["media-library", "media-library"],
|
|
["motion", "motion"],
|
|
["reminders", "reminders"],
|
|
];
|
|
for (const [permission, service] of cases) {
|
|
execFileMock.mockClear();
|
|
const result = await iosImpl.handler({}, params({ permission }), iosDevice);
|
|
// `location` grant runs a `get_app_container` install probe first, so the
|
|
// privacy call isn't necessarily calls[0] — find it by verb.
|
|
const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy");
|
|
expect(privacyCall?.[1], permission).toEqual([
|
|
"simctl",
|
|
"privacy",
|
|
IOS_UDID,
|
|
"grant",
|
|
service,
|
|
"com.example.app",
|
|
]);
|
|
expect(result.applied, permission).toEqual([service]);
|
|
}
|
|
});
|
|
|
|
it("photos fans out to the `photos` and `photos-add` TCC services", async () => {
|
|
// add-only access lives in the separate `photos-add` service, so a deny/reset
|
|
// that touched only `photos` would leave a surviving add-only grant. Both are
|
|
// targeted; `photos-add` is best-effort (see the next test).
|
|
execFileSucceeds();
|
|
for (const action of ["grant", "deny", "reset"] as const) {
|
|
execFileMock.mockClear();
|
|
const result = await iosImpl.handler({}, params({ action, permission: "photos" }), iosDevice);
|
|
const services = execFileMock.mock.calls.map((c) => (c[1] as string[])[4]);
|
|
expect(services, action).toEqual(["photos", "photos-add"]);
|
|
expect(result.applied, action).toEqual(["photos", "photos-add"]);
|
|
}
|
|
});
|
|
|
|
it("a runtime that rejects the best-effort `photos-add` service still succeeds on `photos`", async () => {
|
|
// `photos` (the primary) must succeed; a `photos-add` this runtime doesn't
|
|
// model must be skipped silently, not fail the whole action.
|
|
execFileMock.mockImplementation(
|
|
(_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => {
|
|
if (args[4] === "photos-add") {
|
|
cb(Object.assign(new Error("Failed to set access"), { code: 1 }));
|
|
} else {
|
|
cb(null, "");
|
|
}
|
|
}
|
|
);
|
|
const result = await iosImpl.handler({}, params({ permission: "photos" }), iosDevice);
|
|
expect(result.applied).toEqual(["photos"]);
|
|
});
|
|
|
|
it("granting location to an uninstalled app fails instead of a false success", async () => {
|
|
// location auth isn't TCC-backed and doesn't persist pre-install, so a grant
|
|
// to a missing app records nothing — verify install first (get_app_container
|
|
// exits non-zero for a missing app) and reject rather than return applied.
|
|
execFileMock.mockImplementation(
|
|
(_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => {
|
|
if (args[0] === "simctl" && args[1] === "get_app_container") {
|
|
cb(Object.assign(new Error("No such file or directory"), { code: 1 }));
|
|
} else {
|
|
cb(null, "");
|
|
}
|
|
}
|
|
);
|
|
for (const permission of ["location", "location-always"] as const) {
|
|
const rejection = expect(
|
|
iosImpl.handler({}, params({ action: "grant", permission }), iosDevice)
|
|
).rejects;
|
|
await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED));
|
|
await rejection.toThrow(/the app is not installed/);
|
|
}
|
|
// The privacy grant must never have run for the missing app.
|
|
const ranPrivacy = execFileMock.mock.calls.some((c) => (c[1] as string[])[1] === "privacy");
|
|
expect(ranPrivacy).toBe(false);
|
|
});
|
|
|
|
it("granting location to an installed app runs the privacy grant", async () => {
|
|
// get_app_container succeeds (installed) → the grant proceeds normally.
|
|
execFileSucceeds();
|
|
const result = await iosImpl.handler(
|
|
{},
|
|
params({ action: "grant", permission: "location" }),
|
|
iosDevice
|
|
);
|
|
expect(result.applied).toEqual(["location"]);
|
|
const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy");
|
|
expect(privacyCall?.[1]).toEqual([
|
|
"simctl",
|
|
"privacy",
|
|
IOS_UDID,
|
|
"grant",
|
|
"location",
|
|
"com.example.app",
|
|
]);
|
|
});
|
|
|
|
it("denying or resetting location does NOT require the app to be installed", async () => {
|
|
// The install guard is grant-only: a deny/reset of location for a missing
|
|
// app is a harmless no-op on device, so it must not probe install state.
|
|
execFileSucceeds();
|
|
for (const action of ["deny", "reset"] as const) {
|
|
execFileMock.mockClear();
|
|
await iosImpl.handler({}, params({ action, permission: "location" }), iosDevice);
|
|
const probed = execFileMock.mock.calls.some(
|
|
(c) => (c[1] as string[])[1] === "get_app_container"
|
|
);
|
|
expect(probed, action).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("granting location on a shutdown simulator surfaces the boot hint, not a false 'not installed'", async () => {
|
|
// get_app_container needs a booted sim: on a shutdown sim it fails with
|
|
// "Unable to lookup in current state: Shutdown" for installed AND missing
|
|
// apps alike, so the install guard must NOT read that as "not installed".
|
|
// It must fall through to the privacy grant, which fails the same way and
|
|
// gets the boot hint. Regression guard: a blanket catch→false previously
|
|
// mislabeled an installed app as uninstalled on a shutdown sim and steered
|
|
// the agent to reinstall instead of boot.
|
|
execFileMock.mockImplementation(
|
|
(_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => {
|
|
cb(
|
|
Object.assign(new Error("Unable to lookup in current state: Shutdown"), {
|
|
code: 149,
|
|
})
|
|
);
|
|
}
|
|
);
|
|
const rejection = expect(
|
|
iosImpl.handler({}, params({ action: "grant", permission: "location" }), iosDevice)
|
|
).rejects;
|
|
await rejection.toThrow(/must be booted first — use boot-device/);
|
|
await rejection.not.toThrow(/the app is not installed/);
|
|
});
|
|
|
|
it("an unrecognized install-probe failure is not a 'not installed' verdict — the grant falls through", async () => {
|
|
// The probe's contract reserves `false` for a definitive "not installed"
|
|
// (the shapes get_app_container emits for a missing app) and `undefined`
|
|
// for "couldn't answer". A stale/deleted UDID ("Invalid device") or a probe
|
|
// killed at its timeout (no recognizable text) must NOT become a definitive
|
|
// verdict: the guard is skipped and the privacy call decides. Regression
|
|
// guard: a blanket catch→false steered the agent to install an app that was
|
|
// already installed whenever the probe itself broke.
|
|
for (const probeError of [
|
|
`Invalid device: ${IOS_UDID}`,
|
|
"Command failed: xcrun simctl get_app_container", // killed at timeout — no stderr text
|
|
]) {
|
|
execFileMock.mockImplementation(
|
|
(
|
|
_cmd: string,
|
|
args: string[],
|
|
_opts: unknown,
|
|
cb: (err: unknown, out?: unknown) => void
|
|
) => {
|
|
if (args[0] === "simctl" && args[1] === "get_app_container") {
|
|
cb(Object.assign(new Error(probeError), { code: 1 }));
|
|
} else {
|
|
cb(null, { stdout: "", stderr: "" });
|
|
}
|
|
}
|
|
);
|
|
execFileMock.mockClear();
|
|
const result = await iosImpl.handler(
|
|
{},
|
|
params({ action: "grant", permission: "location" }),
|
|
iosDevice
|
|
);
|
|
expect(result.applied, probeError).toEqual(["location"]);
|
|
const ranPrivacy = execFileMock.mock.calls.some((c) => (c[1] as string[])[1] === "privacy");
|
|
expect(ranPrivacy, probeError).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("both definitive missing-app probe shapes reject the location grant", async () => {
|
|
// get_app_container's two real missing-app shapes stay definitive verdicts.
|
|
for (const probeError of ["No such file or directory", "com.example.app is not installed"]) {
|
|
execFileMock.mockImplementation(
|
|
(
|
|
_cmd: string,
|
|
args: string[],
|
|
_opts: unknown,
|
|
cb: (err: unknown, out?: unknown) => void
|
|
) => {
|
|
if (args[0] === "simctl" && args[1] === "get_app_container") {
|
|
cb(Object.assign(new Error(probeError), { code: 1 }));
|
|
} else {
|
|
cb(null, { stdout: "", stderr: "" });
|
|
}
|
|
}
|
|
);
|
|
execFileMock.mockClear();
|
|
await expect(
|
|
iosImpl.handler({}, params({ action: "grant", permission: "location" }), iosDevice)
|
|
).rejects.toThrow(/the app is not installed/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("settings-permissions Android branch", () => {
|
|
function params(overrides: Partial<SettingsPermissionsParams>): SettingsPermissionsParams {
|
|
return {
|
|
udid: ANDROID_SERIAL,
|
|
action: "grant",
|
|
permission: "camera",
|
|
bundleId: "com.example.app",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("grant runs `pm grant` for the mapped permission", async () => {
|
|
const result = await androidImpl.handler({}, params({}), androidDevice);
|
|
expect(mockAdbShell).toHaveBeenCalledWith(
|
|
ANDROID_SERIAL,
|
|
"pm grant 'com.example.app' android.permission.CAMERA",
|
|
expect.anything()
|
|
);
|
|
expect(result.applied).toEqual(["android.permission.CAMERA"]);
|
|
expect(result.skipped).toBeUndefined();
|
|
});
|
|
|
|
it("deny runs `pm revoke`", async () => {
|
|
await androidImpl.handler({}, params({ action: "deny" }), androidDevice);
|
|
expect(mockAdbShell).toHaveBeenCalledWith(
|
|
ANDROID_SERIAL,
|
|
"pm revoke 'com.example.app' android.permission.CAMERA",
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("reset revokes and clears the user-set permission flags", async () => {
|
|
await androidImpl.handler({}, params({ action: "reset" }), androidDevice);
|
|
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
|
|
expect(commands).toEqual([
|
|
"pm list packages 'com.example.app'",
|
|
"pm revoke 'com.example.app' android.permission.CAMERA",
|
|
"pm clear-permission-flags 'com.example.app' android.permission.CAMERA user-set user-fixed",
|
|
]);
|
|
});
|
|
|
|
it("reset skips clear-permission-flags when the revoke itself fails", async () => {
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm revoke")) throw new Error("pm revoke exited with code 255");
|
|
return undefined;
|
|
});
|
|
await expect(
|
|
androidImpl.handler({}, params({ action: "reset" }), androidDevice)
|
|
).rejects.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED));
|
|
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
|
|
expect(commands.some((c) => c.startsWith("pm clear-permission-flags"))).toBe(false);
|
|
});
|
|
|
|
it("reset still counts a revoked permission whose flags could not be cleared", async () => {
|
|
// clear-permission-flags is best-effort: it first appears in Android 13
|
|
// (API 33) and can't undo the revoke, so its failure must NOT demote a
|
|
// permission the revoke already changed. Revoke succeeded here -> applied,
|
|
// not skipped, no error (previously this reported a misleading total failure).
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm clear-permission-flags")) {
|
|
throw new Error("pm clear-permission-flags exited with code 255");
|
|
}
|
|
return undefined;
|
|
});
|
|
const result = await androidImpl.handler({}, params({ action: "reset" }), androidDevice);
|
|
expect(result.applied).toEqual(["android.permission.CAMERA"]);
|
|
expect(result.skipped).toBeUndefined();
|
|
});
|
|
|
|
it("a transport-level adb failure at the preflight is rethrown, not mislabeled as not-installed", async () => {
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm list packages")) {
|
|
throw new Error("adb: device 'emulator-5554' not found");
|
|
}
|
|
return undefined;
|
|
});
|
|
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
|
|
await rejection.toThrow(/device 'emulator-5554' not found/);
|
|
await rejection.not.toThrow(/not installed/);
|
|
});
|
|
|
|
it("a slow/unavailable package manager at the preflight is rethrown, not mislabeled as not-installed", async () => {
|
|
// `pm list packages` exits 0 for a missing package, so any THROW here is a
|
|
// real failure (timeout/kill, or pm not up yet right after boot) — it must
|
|
// surface the real cause, not "not installed" (the old `pm path` probe's bug).
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm list packages")) {
|
|
throw new Error("Could not access the Package Manager. Is the system running?");
|
|
}
|
|
return undefined;
|
|
});
|
|
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
|
|
await rejection.toThrow(/Could not access the Package Manager/);
|
|
await rejection.not.toThrow(/not installed/);
|
|
});
|
|
|
|
it("a package pm cannot resolve fails with not-found instead of a silent success", async () => {
|
|
// `pm list packages <pkg>` exits 0 with NO output for a missing package
|
|
// (verified on API 36), so the handler detects the absent `package:` line
|
|
// and never issues a grant/revoke (which would exit 0 for a missing package).
|
|
mockAdbShell.mockImplementation(async (_serial, cmd) => {
|
|
if (cmd.startsWith("pm list packages")) return "";
|
|
return "";
|
|
});
|
|
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
|
|
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
|
|
);
|
|
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
|
|
expect(commands).toEqual(["pm list packages 'com.example.app'"]);
|
|
});
|
|
|
|
it("does not treat a substring package match as installed", async () => {
|
|
// `pm list packages` filters by substring, so a request for `com.example.app`
|
|
// can return only `com.example.app.helper`; the exact-line check must reject
|
|
// that rather than operate on the wrong package.
|
|
mockAdbShell.mockImplementation(async (_serial, cmd) => {
|
|
if (cmd.startsWith("pm list packages")) return "package:com.example.app.helper";
|
|
return "";
|
|
});
|
|
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
|
|
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
|
|
);
|
|
});
|
|
|
|
it("finds the target package among substring siblings", async () => {
|
|
// The realistic installed shape: `pm list packages <pkg>` filters by
|
|
// substring, so an installed app usually returns several lines — the target
|
|
// plus every package whose name contains it (e.g. `com.google.android.gms`
|
|
// returns both `...gms.supervision` and `...gms`). The per-line `.some(...)`
|
|
// must match the exact target line among the siblings, in any order; a switch
|
|
// to whole-output or first-line matching would read a real installed app as
|
|
// "not installed" while every other Android test stayed green.
|
|
mockAdbShell.mockImplementation(async (_serial, cmd) => {
|
|
if (cmd.startsWith("pm list packages")) {
|
|
return "package:com.example.app.helper\npackage:com.example.app\npackage:com.example.app.debug";
|
|
}
|
|
return "";
|
|
});
|
|
const result = await androidImpl.handler({}, params({}), androidDevice);
|
|
expect(result.applied).toEqual(["android.permission.CAMERA"]);
|
|
expect(mockAdbShell).toHaveBeenCalledWith(
|
|
ANDROID_SERIAL,
|
|
"pm grant 'com.example.app' android.permission.CAMERA",
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("location fans out to fine + coarse", async () => {
|
|
const result = await androidImpl.handler({}, params({ permission: "location" }), androidDevice);
|
|
expect(result.applied).toEqual([
|
|
"android.permission.ACCESS_FINE_LOCATION",
|
|
"android.permission.ACCESS_COARSE_LOCATION",
|
|
]);
|
|
});
|
|
|
|
it("granting location-always also grants the foreground location permissions", async () => {
|
|
// Background location alone is unusable on Android — the OS requires the
|
|
// foreground permissions to be granted too.
|
|
const result = await androidImpl.handler(
|
|
{},
|
|
params({ permission: "location-always" }),
|
|
androidDevice
|
|
);
|
|
expect(result.applied).toEqual([
|
|
"android.permission.ACCESS_FINE_LOCATION",
|
|
"android.permission.ACCESS_COARSE_LOCATION",
|
|
"android.permission.ACCESS_BACKGROUND_LOCATION",
|
|
]);
|
|
});
|
|
|
|
it("denying location-always touches only the background permission", async () => {
|
|
const result = await androidImpl.handler(
|
|
{},
|
|
params({ action: "deny", permission: "location-always" }),
|
|
androidDevice
|
|
);
|
|
expect(result.applied).toEqual(["android.permission.ACCESS_BACKGROUND_LOCATION"]);
|
|
});
|
|
|
|
it("maps each abstract permission to its concrete android.permission.* set", async () => {
|
|
// Lock the ANDROID_PERMISSIONS table: camera/photos/location(-always) are
|
|
// covered above, this pins the rest so an accidental emptying/typo of a
|
|
// mapping surfaces here instead of silently granting the wrong permission or
|
|
// reporting "unsupported". Notably notifications→POST_NOTIFICATIONS (a
|
|
// primary Android use case) and the two-permission contacts/calendar/
|
|
// media-library fan-outs, none of which had a mapping assertion.
|
|
const cases: Array<[SettingsPermissionsParams["permission"], string[]]> = [
|
|
["microphone", ["android.permission.RECORD_AUDIO"]],
|
|
["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"]],
|
|
["notifications", ["android.permission.POST_NOTIFICATIONS"]],
|
|
["calendar", ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"]],
|
|
[
|
|
"media-library",
|
|
["android.permission.READ_MEDIA_AUDIO", "android.permission.READ_EXTERNAL_STORAGE"],
|
|
],
|
|
["motion", ["android.permission.ACTIVITY_RECOGNITION"]],
|
|
];
|
|
for (const [permission, expected] of cases) {
|
|
const result = await androidImpl.handler({}, params({ permission }), androidDevice);
|
|
expect(result.applied, permission).toEqual(expected);
|
|
}
|
|
});
|
|
|
|
it("partial pm failures (a real device throws on exit 255) land in `skipped`, not an error", async () => {
|
|
// photos → READ_MEDIA_IMAGES ok, READ_MEDIA_VIDEO ok, READ_EXTERNAL_STORAGE
|
|
// rejected. On a real device (verified on API 34) `pm grant` for a
|
|
// non-changeable permission exits 255 and writes the SecurityException to
|
|
// stderr, so adbShell THROWS and the failure is handled in runPm's catch
|
|
// branch (not the exit-0 stdout inspection). Exercise that realistic path.
|
|
adbDefaults((cmd) => {
|
|
if (cmd.includes("READ_EXTERNAL_STORAGE")) {
|
|
throw new Error(
|
|
"adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.READ_EXTERNAL_STORAGE failed: " +
|
|
"java.lang.SecurityException: Permission android.permission.READ_EXTERNAL_STORAGE requested by com.example.app is not a changeable permission type\n" +
|
|
"\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)"
|
|
);
|
|
}
|
|
return undefined;
|
|
});
|
|
const result = await androidImpl.handler({}, params({ permission: "photos" }), androidDevice);
|
|
expect(result.applied).toEqual([
|
|
"android.permission.READ_MEDIA_IMAGES",
|
|
"android.permission.READ_MEDIA_VIDEO",
|
|
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
|
]);
|
|
expect(result.skipped).toEqual(["android.permission.READ_EXTERNAL_STORAGE"]);
|
|
});
|
|
|
|
it("photos includes READ_MEDIA_VISUAL_USER_SELECTED so partial-access is cleared", async () => {
|
|
// On API 34+ the platform auto-adds USER_SELECTED alongside READ_MEDIA_*, and
|
|
// the partial-access dialog grants it persistently. Omitting it would let
|
|
// `deny photos` leave the app passing its partial-access check. Pin that the
|
|
// mapping fans out to it (pre-34 devices reject it → it lands in `skipped`).
|
|
const result = await androidImpl.handler(
|
|
{},
|
|
params({ action: "deny", permission: "photos" }),
|
|
androidDevice
|
|
);
|
|
expect(result.applied).toContain("android.permission.READ_MEDIA_VISUAL_USER_SELECTED");
|
|
expect(mockAdbShell).toHaveBeenCalledWith(
|
|
ANDROID_SERIAL,
|
|
"pm revoke 'com.example.app' android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it("pm rejecting every mapped permission (thrown on exit 255) raises the failure and strips Java stack frames", async () => {
|
|
// The realistic API 34+ path: pm exits 255, adbShell throws, and the message
|
|
// (built from adb's stderr) carries the exception plus its Java stack frames.
|
|
// runPm's catch branch runs stripStackFrames on the THROWN err.message.
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm grant")) {
|
|
throw new Error(
|
|
"adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.CAMERA failed: " +
|
|
"java.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA\n" +
|
|
"\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)\n" +
|
|
"\tat android.os.Binder.execTransact(Binder.java:1275)"
|
|
);
|
|
}
|
|
return undefined;
|
|
});
|
|
const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects;
|
|
await rejection.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED));
|
|
await rejection.toSatisfy(
|
|
(err: unknown) =>
|
|
err instanceof Error &&
|
|
err.message.includes("has not requested permission") &&
|
|
!err.message.includes("at com.android.server") &&
|
|
!err.message.includes("at android.os.Binder")
|
|
);
|
|
});
|
|
|
|
it("pm reporting a failure as exit-0 stdout is still treated as a failure", async () => {
|
|
// Some pm builds print the SecurityException to stdout and exit 0; runPm's
|
|
// stdout inspection must still classify a non-`Success` output as a failure.
|
|
adbDefaults((cmd) =>
|
|
cmd.startsWith("pm grant")
|
|
? "Exception occurred while executing 'grant':\njava.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA"
|
|
: undefined
|
|
);
|
|
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
|
|
failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)
|
|
);
|
|
});
|
|
|
|
it("reminders is rejected as unsupported (no Android equivalent)", async () => {
|
|
await expect(
|
|
androidImpl.handler({}, params({ permission: "reminders" }), androidDevice)
|
|
).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED));
|
|
expect(mockAdbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a device dropping mid-fan-out propagates the transport error, not a `skipped` entry", async () => {
|
|
// If the device disconnects after one permission already applied, the
|
|
// remaining pm calls are not manifest rejections — the transport is dead.
|
|
// The handler must surface adb's real cause (a terminal "device not found"),
|
|
// not return success with the dead-device permissions in `skipped`.
|
|
adbDefaults((cmd) => {
|
|
if (cmd.includes("READ_MEDIA_VIDEO"))
|
|
throw new Error("adb: device 'emulator-5554' not found");
|
|
return undefined;
|
|
});
|
|
const rejection = expect(
|
|
androidImpl.handler({}, params({ permission: "photos" }), androidDevice)
|
|
).rejects;
|
|
await rejection.toThrow(/device 'emulator-5554' not found/);
|
|
await rejection.not.toThrow(/every mapped runtime permission was rejected/);
|
|
});
|
|
|
|
it("an adb daemon-leg failure mid-fan-out propagates as transport, not a `skipped` entry", async () => {
|
|
// The adb client↔daemon leg (server restarting / daemon down) fails with
|
|
// shapes `isTerminalAdbError` doesn't match — `Connection reset by peer`,
|
|
// `cannot connect to daemon`. They still mean the pm call never ran, so the
|
|
// handler must propagate them, not fold the lost permission into `skipped`
|
|
// under a success result (which would report a revoke that never happened).
|
|
adbDefaults((cmd) => {
|
|
if (cmd.includes("READ_MEDIA_VIDEO"))
|
|
throw new Error("adb: protocol fault (couldn't read status): Connection reset by peer");
|
|
return undefined;
|
|
});
|
|
const rejection = expect(
|
|
androidImpl.handler({}, params({ action: "deny", permission: "photos" }), androidDevice)
|
|
).rejects;
|
|
await rejection.toThrow(/Connection reset by peer/);
|
|
await rejection.not.toThrow(/every mapped runtime permission was rejected/);
|
|
});
|
|
|
|
it("a timed-out pm call propagates the classified FailureError with its telemetry intact", async () => {
|
|
// adbShell classifies a killed/timed-out call as a FailureError with
|
|
// error_kind "timeout" + subprocess metadata. A wedged device must keep that
|
|
// classification, not be relabelled as a generic manifest failure — the iOS
|
|
// branch forwards the same metadata, and sibling Android tools propagate it.
|
|
const timeoutErr = new FailureError("pm grant ... (killed=true signal=SIGKILL)", {
|
|
error_code: FAILURE_CODES.ANDROID_ADB_COMMAND_FAILED,
|
|
failure_stage: "android_adb_command",
|
|
failure_area: "tool_server",
|
|
error_kind: "timeout",
|
|
});
|
|
adbDefaults((cmd) => {
|
|
if (cmd.startsWith("pm grant")) throw timeoutErr;
|
|
return undefined;
|
|
});
|
|
await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy(
|
|
(err: unknown) => err === timeoutErr && getFailureSignal(err)?.error_kind === "timeout"
|
|
);
|
|
});
|
|
|
|
it("resetting location-always touches only the background permission (not fine/coarse)", async () => {
|
|
// The grant→foreground fan-out is grant-only: a reset must not also revoke
|
|
// ACCESS_FINE/COARSE_LOCATION ("taking away 'always' shouldn't strip 'while
|
|
// in use'"). Pins that the fan-out condition stays `action === "grant"` — a
|
|
// loosening to `action !== "deny"` would revoke fine+coarse here.
|
|
await androidImpl.handler(
|
|
{},
|
|
params({ action: "reset", permission: "location-always" }),
|
|
androidDevice
|
|
);
|
|
const commands = mockAdbShell.mock.calls.map((c) => c[1]);
|
|
expect(commands).toEqual([
|
|
"pm list packages 'com.example.app'",
|
|
"pm revoke 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION",
|
|
"pm clear-permission-flags 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION user-set user-fixed",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("settings-permissions dispatch wiring (through tool.execute)", () => {
|
|
// The per-branch tests above call iosImpl/androidImpl directly, so they can't
|
|
// catch a mis-wired dispatch table (e.g. `ios: androidImpl, android: iosImpl`,
|
|
// which typechecks and would run `pm` against iOS UDIDs). These drive the real
|
|
// `execute` with shaped udids and assert each platform reaches its OWN binary
|
|
// — xcrun for iOS, adb for Android, sim-remote for ios-remote — so any swap of
|
|
// the branches fails here. Dep cache is primed so `ensureDeps` doesn't shell
|
|
// out to `command -v` and perturb `execFileMock` call counts.
|
|
beforeEach(() => {
|
|
__resetDepCacheForTests();
|
|
__primeDepCacheForTests(["xcrun", "adb", "sim-remote"]);
|
|
});
|
|
|
|
it("an iOS udid runs `xcrun simctl privacy`, never adb", async () => {
|
|
execFileSucceeds();
|
|
const result = await settingsPermissionsTool.execute(
|
|
{},
|
|
{ udid: IOS_UDID, action: "grant", permission: "microphone", bundleId: "com.example.app" }
|
|
);
|
|
expect(result.applied).toEqual(["microphone"]);
|
|
const [cmd, args] = execFileMock.mock.calls[0]!;
|
|
expect(cmd).toBe("xcrun");
|
|
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
|
|
expect(mockAdbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("an Android serial runs `pm` over adb, never xcrun", async () => {
|
|
// Give execFile a resolving impl so an accidental ios/android swap fails via
|
|
// a clean assertion (wrong `applied` shape) instead of hanging on an
|
|
// unconfigured mock that never invokes its callback.
|
|
execFileSucceeds();
|
|
const result = await settingsPermissionsTool.execute(
|
|
{},
|
|
{ udid: ANDROID_SERIAL, action: "grant", permission: "camera", bundleId: "com.example.app" }
|
|
);
|
|
expect(result.applied).toEqual(["android.permission.CAMERA"]);
|
|
expect(mockAdbShell).toHaveBeenCalledWith(
|
|
ANDROID_SERIAL,
|
|
"pm grant 'com.example.app' android.permission.CAMERA",
|
|
expect.anything()
|
|
);
|
|
expect(execFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a `remote:` udid routes to the ios-remote branch (sim-remote)", async () => {
|
|
// The ios-remote branch + appleRemote capability let a sim-remote setup
|
|
// pre-set permissions, matching the launch-app / open-url family. sim-remote
|
|
// shells out via the same execFile, so assert the `sim-remote` invocation.
|
|
execFileSucceeds();
|
|
const result = await settingsPermissionsTool.execute(
|
|
{},
|
|
{
|
|
udid: `remote:${IOS_UDID}`,
|
|
action: "grant",
|
|
permission: "microphone",
|
|
bundleId: "com.example.app",
|
|
}
|
|
);
|
|
expect(result.applied).toEqual(["microphone"]);
|
|
const [cmd, args] = execFileMock.mock.calls[0]!;
|
|
expect(cmd).toBe("sim-remote");
|
|
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
|
|
expect(mockAdbShell).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("`grant location` on a `remote:` udid skips the install probe (sim-remote has no app-container verb)", async () => {
|
|
// The remote backend omits `isInstalled`, so the location pre-grant guard is
|
|
// skipped there by design — a pre-install grant reports success while
|
|
// recording nothing, which the SKILL/tool description scope to local sims.
|
|
// Pin the skip so a future remote install probe (or a guard change that
|
|
// starts requiring one) shows up as a deliberate contract change here.
|
|
execFileSucceeds();
|
|
const result = await settingsPermissionsTool.execute(
|
|
{},
|
|
{
|
|
udid: `remote:${IOS_UDID}`,
|
|
action: "grant",
|
|
permission: "location",
|
|
bundleId: "com.example.app",
|
|
}
|
|
);
|
|
expect(result.applied).toEqual(["location"]);
|
|
const probed = execFileMock.mock.calls.some((c) =>
|
|
(c[1] as string[]).includes("get_app_container")
|
|
);
|
|
expect(probed).toBe(false);
|
|
const [cmd, args] = execFileMock.mock.calls[0]!;
|
|
expect(cmd).toBe("sim-remote");
|
|
expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]);
|
|
});
|
|
});
|