Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions src/commands/capture/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,20 +193,43 @@ describe('settings CLI permission vocabulary', () => {
});
});

// The flag bag carries a configured default app the parser strips for settings; the reader still
// gets exercised with one present, so the clear-app-state branch cannot regress into letting
// `--app` redirect this destructive mutation away from the positional id.
// The parser strips a configured default before the reader runs, so a `--app` the reader sees was
// typed on this invocation. A destructive clear must land on the app the caller named, never fall
// through to the session app or pick one of two named apps.
test('keeps a clear-app-state app positional and out of the request input', () => {
const input = settingsCliReader(['clear-app-state', 'com.example.app'], {
targetApp: 'com.example.other',
} as CliFlags);
const input = settingsCliReader(['clear-app-state', 'com.example.app'], flags());
const writer = settingsDaemonWriter(input);
expect(writer.positionals).toEqual(['clear-app-state', 'com.example.app']);
expect(writer.input).toBeUndefined();
// The app the input carries is the positional, never the flag bag's default.
expect(input).toMatchObject({ app: 'com.example.app' });
});

test.each([[['clear-app-state']], [['clear-app-state', 'clear']]])(
'aims %j at the --app it names when no positional does',
(positionals) => {
const input = settingsCliReader(positionals, { targetApp: 'com.example.app' } as CliFlags);
expect(settingsDaemonWriter(input).positionals).toEqual([
'clear-app-state',
'com.example.app',
]);
},
);

test('accepts the same app named positionally and with --app', () => {
const input = settingsCliReader(['clear-app-state', 'clear', 'com.example.app'], {
targetApp: 'com.example.app',
} as CliFlags);
expect(settingsDaemonWriter(input).positionals).toEqual(['clear-app-state', 'com.example.app']);
});

test('refuses a clear-app-state that names two different apps', () => {
expect(() =>
settingsCliReader(['clear-app-state', 'com.example.app'], {
targetApp: 'com.example.other',
} as CliFlags),
).toThrow(/names two apps: com.example.app and com.example.other/);
});

test('omits the request input when no app is named', () => {
expect(
settingsDaemonWriter(settingsCliReader(['animations', 'off'], flags())).input,
Expand Down
28 changes: 22 additions & 6 deletions src/commands/capture/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ const settingsCliSchema = {

export const settingsCliReader: CliReader = (positionals, flags) => {
const options = readSettingsOptionsFromPositionals(positionals, flags);
// `clear-app-state` already names its app positionally, so `--app` fills the slot only for the
// settings whose app has no positional. Whether a mutation can consume an app at all is the
// daemon's scope table to decide once it knows the resolved target.
// `clear-app-state` resolves `--app` against its own positional slot while it parses, so the
// option is added here only for the settings whose app has no positional. Whether a mutation can
// consume an app at all is the daemon's scope table to decide once it knows the resolved target.
return options.setting === 'clear-app-state' || flags.targetApp === undefined
? options
: { ...options, app: flags.targetApp };
Expand Down Expand Up @@ -105,7 +105,7 @@ export const settingsCommandFacet = defineCommandFacet({
name: SETTINGS_COMMAND_NAME,
text: {
summary: 'Change OS settings and app permissions',
cliDetail: `macOS supports only settings appearance <light|dark|toggle> and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app <id> (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open <app> --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size <category> applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`,
cliDetail: `macOS supports only settings appearance <light|dark|toggle> and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app <id> (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open <app> --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size <category> applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`,
},
metadata: settingsCommandMetadata,
run: (client, input) => client.settings.update(input as SettingsUpdateOptions),
Expand Down Expand Up @@ -163,15 +163,31 @@ function readSettingsOptionsFromPositionals(
};
}
if (setting === 'clear-app-state') {
const app = state === 'clear' ? positionals[2] : state;
return { ...base, setting, state: 'clear', app };
return { ...base, setting, state: 'clear', app: readClearAppStateApp(positionals, flags) };
}
if (setting === 'reset-keychain' && state === 'clear' && positionals.length === 2) {
return { ...base, setting, state };
}
throw new AppError('INVALID_ARGS', 'Invalid settings arguments.');
}

/**
* The app `clear-app-state` clears: its positional, or `--app` when no positional names one. A
* positional and a different `--app` is a contradiction refused here, because clearing either one
* would silently drop the other, and an unnamed slot would fall through to the session app.
*/
function readClearAppStateApp(positionals: string[], flags: CliFlags): string | undefined {
const positionalApp = positionals[1] === 'clear' ? positionals[2] : positionals[1];
const flagApp = flags.targetApp;
if (positionalApp !== undefined && flagApp !== undefined && positionalApp !== flagApp) {
throw new AppError(
'INVALID_ARGS',
`settings clear-app-state names two apps: ${positionalApp} and ${flagApp}. Name one.`,
);
}
return positionalApp ?? flagApp;
}

function settingsPositionals(input: SettingsUpdateOptions): string[] {
if (input.setting === 'clear-app-state') {
return [input.setting, ...optionalString(input.app)];
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ agent-device settings permission reset screen-recording --platform macos
- Android has no category ladder, only a `font_scale` multiplier, so the two are mapped: `large` is `1.0` up through `accessibility-extra-extra-extra-large` at `3.2`. A device holding a multiplier off that ladder reads back as the nearest rung with the exact multiplier as `platformValue`, and an unset `font_scale` reads as `large`/`1.0`.
- A text size change is a configuration change: an app already running adopts it on its next configuration change, so relaunch the app under test (`open <app> --relaunch`) when asserting rendered sizes.
- `settings location set <lat> <lon>` sets precise coordinates on iOS simulators and Android emulators.
- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it.
- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. The app id can also be named with `--app` (`settings clear-app-state --app com.example.app`); naming two different apps, one positionally and one with `--app`, is refused with `INVALID_ARGS`. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it.
- `settings reset-keychain clear` resets the iOS simulator's keychain (`xcrun simctl keychain <device> reset`), removing keychain-backed credentials such as Firebase auth tokens that `clear-app-state` leaves behind. simctl has no per-app keychain reset, so this clears the keychain for every app installed on that simulator, not only the app under test — treat it as a whole-simulator, opt-in operation and pair it with `clear-app-state` for a full fresh-install reset. iOS physical devices, Android, and macOS are unsupported.
- Face ID and Touch ID controls are iPhone and iPad simulator-only; Apple TV and Vision Pro simulators refuse them. They post the same Darwin notifications the Simulator.app Features menu posts (`xcrun simctl spawn <device> notifyutil`): `com.apple.BiometricKit_Sim.<pearl|fingerTouch>.<match|nomatch>` for match/nonmatch, and `com.apple.BiometricKit.enrollmentChanged` (set to `1`/`0`, then posted, then read back) for enroll/unenroll. No `simctl biometric` subcommand exists, so this route works on any Xcode.
- Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately.
Expand Down
Loading