diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index c7705a6742..f5e6c552ba 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -25,10 +25,14 @@ const retryActual = await vi.importActual('../simulator.ts'); -import { setIosSetting } from '../app-settings.ts'; +import { IOS_NO_DATA_CONTAINER_REASON, setIosSetting } from '../app-settings.ts'; import { withMockedMacOsHelper } from './macos-helper-test-utils.ts'; import { ensureBootedSimulator } from '../simulator.ts'; -import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; +import { + AppError, + normalizeError, + PRE_DISPATCH_REFUSAL_REASONS, +} from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { retryWithPolicy } from '@agent-device/host-kit/retry'; import { assertRejectsAppError } from '../../__tests__/app-error.ts'; @@ -353,6 +357,81 @@ test('setIosSetting appearance runs the simctl plan on the simulator udid and re ); }); +// Production `simctl` answers an installed app with no data container (com.apple.Preferences on a +// booted simulator, issue #3305) by exiting 0 and printing the literal `(null)`; the double carries +// that exact answer so the old code reached readHostDirectory('(null)') and failed the way +// production failed — an ENOENT crash instead of a refusal. +test('setIosSetting clear-app-state refuses a system app whose simctl answer is (null) with the typed reason', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl terminate sim-1 com.apple.Preferences') return ''; + if (args.join(' ') === 'simctl get_app_container sim-1 com.apple.Preferences data') { + return '(null)\n'; + } + return unexpectedArgs(args); + }, + async ({ calls }) => { + const thrown = await setIosSetting( + IOS_TEST_SIMULATOR, + 'clear-app-state', + 'clear', + 'com.apple.Preferences', + ).catch((error: unknown) => error); + assert.ok(thrown instanceof AppError); + assert.equal(thrown.code, 'UNSUPPORTED_OPERATION'); + // The refusal is keyed on the typed reason, not on prose a driver must not match. + assert.equal(thrown.details?.reason, IOS_NO_DATA_CONTAINER_REASON); + assert.equal(thrown.details?.appBundleId, 'com.apple.Preferences'); + assert.equal(thrown.details?.deviceId, 'sim-1'); + // Normalization keeps the reason, hint, and typed details intact through the wire shape. + const normalized = normalizeError(thrown); + assert.equal(normalized.code, 'UNSUPPORTED_OPERATION'); + assert.match(normalized.message, /com\.apple\.Preferences has no data container to clear/); + assert.match(normalized.hint ?? '', /Clear the app under test instead/); + assert.equal(normalized.details?.reason, IOS_NO_DATA_CONTAINER_REASON); + assert.equal(normalized.details?.appBundleId, 'com.apple.Preferences'); + + const flat = calls.map((args) => args.join(' ')); + assert.equal( + flat.includes('simctl get_app_container sim-1 com.apple.Preferences data'), + true, + flat.join('; '), + ); + }, + ); +}); + +// The closest negative: the message cannot be the trigger. An empty answer is the same "no +// container" fact from the tool and takes the same typed refusal, while a real path must still be +// cleared rather than refused (pinned by the fresh-install layout test below). +test('setIosSetting clear-app-state refuses an empty container answer with the same typed reason', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool( + (args) => { + if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (args.join(' ') === 'simctl terminate sim-1 com.apple.Preferences') return ''; + if (args.join(' ') === 'simctl get_app_container sim-1 com.apple.Preferences data') { + return ''; + } + return unexpectedArgs(args); + }, + async () => { + await assertRejectsAppError( + () => + setIosSetting(IOS_TEST_SIMULATOR, 'clear-app-state', 'clear', 'com.apple.Preferences'), + { + code: 'UNSUPPORTED_OPERATION', + reason: IOS_NO_DATA_CONTAINER_REASON, + }, + ); + }, + ); +}); + test('setIosSetting clear-app-state leaves a fresh-install data container layout', async () => { const containerPath = await mkdtempForTest('agent-device-ios-clear-app-state-container-'); const metadataFile = '.com.apple.mobile_container_manager.metadata.plist'; diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 247cb01f29..01960d1c13 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -474,6 +474,38 @@ test('resolveSimulatorRunnerScreenshotCandidatePaths handles empty runner path', assert.deepEqual(resolveSimulatorRunnerScreenshotCandidatePaths('/tmp/container', ' '), []); }); +// Production `simctl` answers an installed app that owns no data container with exit 0 and the +// literal `(null)` on stdout. The runner container loop reads that as the tool's "no container" +// answer — the same rule clear-app-state refuses on — and never hands the sentinel to the host +// filesystem as a path. Without the shared classifier, the thrown wrap below carries a copy ENOENT +// from a candidate path built on `(null)` instead. +test('captureScreenshotViaRunner treats a (null) data-container answer as no container', async () => { + const tmpDir = await mkdtempForTest('agent-device-runner-null-container-'); + const device = { ...IOS_TEST_SIMULATOR, id: 'sim-runner-null-container' }; + mockRunAppleRunnerCommand.mockResolvedValue({ message: 'tmp/null.png' }); + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.includes('get_app_container')) { + return { exitCode: 0, stdout: '(null)\n', stderr: '' }; + } + throw new Error(`Unexpected xcrun args: ${args.join(' ')}`); + }); + + try { + const outPath = path.join(tmpDir, 'out.png'); + await assert.rejects( + () => captureScreenshotViaRunner(device, outPath), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /returned no data container path/); + return true; + }, + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('captureScreenshotViaRunner reuses a verified simulator container path', async () => { const tmpDir = await mkdtempForTest('agent-device-runner-cache-'); const containerPath = path.join(tmpDir, 'container'); diff --git a/packages/platform-apple/src/core/__tests__/simctl.test.ts b/packages/platform-apple/src/core/__tests__/simctl.test.ts index b82cd4b3f9..16192b32f3 100644 --- a/packages/platform-apple/src/core/__tests__/simctl.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { buildSimctlArgsForAddress, buildSimctlArgsForDevice, + readSimctlContainerPath, readSimctlDevicesByRuntime, readSimctlDeviceState, scopeSimctlArgsForDevice, @@ -156,3 +157,13 @@ test('readSimctlDevicesByRuntime keys each device list by its runtime', () => { assert.deepEqual(readSimctlDevicesByRuntime('{}'), {}); assert.throws(() => readSimctlDevicesByRuntime('not json'), SyntaxError); }); + +// Production prints these verbatim from `xcrun simctl get_app_container sim-1 data`: +// an app with no container exits 0 and prints the literal `(null)` or nothing at all. +test('readSimctlContainerPath reads the printed path and rejects the no-container answers', () => { + assert.equal(readSimctlContainerPath('/containers/data/App/abc\n'), '/containers/data/App/abc'); + assert.equal(readSimctlContainerPath('(null)\n'), undefined); + assert.equal(readSimctlContainerPath(' (null) '), undefined); + assert.equal(readSimctlContainerPath(''), undefined); + assert.equal(readSimctlContainerPath(' \n'), undefined); +}); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index adfaefe79b..57aadeb7b9 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -28,7 +28,7 @@ import { applySimctlSetting } from './simctl-settings.ts'; import { readIosTextSize, setIosTextSize } from './settings-text-size.ts'; import { requireHandheldAppleSimulatorLeaf } from './settings-leaf.ts'; import { resolveIosApp } from './app-resolution.ts'; -import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts'; +import { buildSimctlArgsForDevice, readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; import { invalidateSimulatorStatusBarOverrideCache, rememberClearedStatusBarOverrides, @@ -194,6 +194,23 @@ const FRESH_INSTALL_DATA_DIRECTORIES = [ 'tmp', ]; +/** + * `details.reason` of a `clear-app-state` refused because the installed app owns no data container. + * A driver branches on this rather than the prose: there is nothing to delete, so retrying cannot + * help. The motivating case ships inside the simulator runtime and cannot be uninstalled, so the + * hint may only point at recoveries a caller actually has: reset the simulator, or uninstall an + * app they installed themselves. + */ +export const IOS_NO_DATA_CONTAINER_REASON = 'app-no-data-container'; + +const IOS_NO_DATA_CONTAINER_HINT = + 'Clear the app under test instead. A runtime-shipped app keeps no data container, so this command has no app state to remove; to reset it, erase or recreate the simulator device, or uninstall and reinstall the app if you installed it yourself.'; + +/** The agent-facing explanation for an app `simctl get_app_container` answers with no container for. */ +function iosNoDataContainerMessage(bundleId: string): string { + return `${bundleId} has no data container to clear. Apps shipped in the simulator runtime, such as system apps, own no data container, so there is no app state here to remove.`; +} + async function clearIosSimulatorAppState( device: DeviceInfo, app: string, @@ -216,12 +233,17 @@ async function clearIosSimulatorAppState( `simctl get_app_container failed for ${bundleId}`, ); - const containerPath = result.stdout.trim(); - if (!containerPath) { - throw new AppError( - 'COMMAND_FAILED', - `simctl get_app_container returned an empty data container path for ${bundleId}`, - ); + // `simctl` answers an app with no data container by exiting 0 and printing nothing or the literal + // `(null)` — a system app is installed and running with nothing to delete. Both are the tool's own + // "no container" answer, refused here rather than handed to the host filesystem as a path. + const containerPath = readSimctlContainerPath(result.stdout); + if (containerPath === undefined) { + throw new AppError('UNSUPPORTED_OPERATION', iosNoDataContainerMessage(bundleId), { + reason: IOS_NO_DATA_CONTAINER_REASON, + appBundleId: bundleId, + deviceId: device.id, + hint: IOS_NO_DATA_CONTAINER_HINT, + }); } const entries = await readHostDirectory(containerPath); diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index 2b9d60e701..23bc3e3627 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -34,7 +34,7 @@ import { type AppleDeviceDisplay, } from './display-inventory.ts'; import { ensureBootedSimulator } from './simulator.ts'; -import { runSimctlForDevice } from './simctl.ts'; +import { readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; import { appleToolFailureText, extractAppleToolErrorMeta } from './tool-diagnostics.ts'; import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; @@ -317,9 +317,9 @@ async function copyRunnerScreenshotFromSimulator( } continue; } - const containerPath = containerResult.stdout.trim(); - if (!containerPath) { - lastError = 'simctl get_app_container returned empty output'; + const containerPath = readSimctlContainerPath(containerResult.stdout); + if (containerPath === undefined) { + lastError = 'simctl get_app_container returned no data container path'; continue; } const copy = await tryCopySimulatorRunnerScreenshot(containerPath, remoteFileName, outPath); diff --git a/packages/platform-apple/src/core/simctl.ts b/packages/platform-apple/src/core/simctl.ts index 0d2ab26236..db5e65485f 100644 --- a/packages/platform-apple/src/core/simctl.ts +++ b/packages/platform-apple/src/core/simctl.ts @@ -93,3 +93,22 @@ export function readSimctlDeviceState(stdout: string, udid: string): string | nu return null; } } + +/** + * The literal `simctl get_app_container` prints on stdout when the app is installed but owns no + * container in the requested domain — a system app has no `data` container — while still exiting 0. + * It is the tool's own "no container" answer, not a path, so callers must never treat it as one. + */ +const SIMCTL_NULL_CONTAINER_PATH = '(null)'; + +/** + * The container path `simctl get_app_container` printed, or `undefined` when it answered that no + * container exists: an empty stdout or the `(null)` sentinel on an otherwise-successful exit. One + * reader for every caller that turns that stdout into a host path, so the sentinel cannot be + * mistaken for a directory at one site and handled at another. + */ +export function readSimctlContainerPath(stdout: string): string | undefined { + const trimmed = stdout.trim(); + if (!trimmed || trimmed === SIMCTL_NULL_CONTAINER_PATH) return undefined; + return trimmed; +} diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 82d4927a42..2973390163 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -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 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 (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 --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 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 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 (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. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. 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 --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 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), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8561afd7ee..5100dff4c7 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -736,7 +736,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 --relaunch`) when asserting rendered sizes. - `settings location set ` 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. 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 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`. An installed app that owns no data container — the apps shipped inside the simulator runtime, such as `com.apple.Preferences` — refuses with `UNSUPPORTED_OPERATION` and `error.details.reason: app-no-data-container`: `simctl` answers its container lookup with "no container", so there is no app state to remove. Runtime-shipped apps cannot be uninstalled, so resetting one means erasing or recreating the simulator device; an app you installed yourself can be uninstalled and reinstalled. 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 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 notifyutil`): `com.apple.BiometricKit_Sim..` 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.