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 0377b1fd61..6bb63c73c0 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -389,11 +389,20 @@ test('setIosSetting permission grant all passes all through as one simctl call', ); }); -test('setIosSetting clear-app-state wipes app data and keeps the container manager metadata', async () => { +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'; - await fs.mkdir(path.join(containerPath, 'Documents'), { recursive: true }); - await fs.writeFile(path.join(containerPath, 'Documents', 'db.sqlite'), 'db'); + for (const [file, content] of Object.entries({ + 'Documents/db.sqlite': 'db', + 'Library/Caches/blob': 'cache', + 'Library/Preferences/com.example.app.plist': 'prefs', + 'Library/Application Support/state.json': 'state', + 'SystemData/com.apple.state': 'system', + 'tmp/download.tmp': 'partial download', + })) { + await fs.mkdir(path.dirname(path.join(containerPath, file)), { recursive: true }); + await fs.writeFile(path.join(containerPath, file), content); + } await fs.writeFile(path.join(containerPath, 'Library.plist'), 'prefs'); await fs.writeFile(path.join(containerPath, '.app-dotfile'), 'app data'); await fs.writeFile(path.join(containerPath, metadataFile), 'metadata'); @@ -416,7 +425,15 @@ test('setIosSetting clear-app-state wipes app data and keeps the container manag ); assert.equal(result?.cleared, true); assert.equal(result?.bundleId, 'com.example.app'); - assert.deepEqual(await fs.readdir(containerPath), [metadataFile]); + assert.deepEqual((await fs.readdir(containerPath, { recursive: true })).sort(), [ + metadataFile, + 'Documents', + 'Library', + 'Library/Caches', + 'Library/Preferences', + 'SystemData', + 'tmp', + ]); assert.equal(await fs.readFile(path.join(containerPath, metadataFile), 'utf8'), 'metadata'); const flat = calls.map((args) => args.join(' ')); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 7d6f5ef069..933ad1e617 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -16,7 +16,11 @@ import { summarizeCommandAttemptFailures, type CommandAttemptFailure, } from '@agent-device/kernel/errors'; -import { readHostDirectory, removeHostPath } from '@agent-device/host-kit/host-file'; +import { + ensureHostDirectory, + readHostDirectory, + removeHostPath, +} from '@agent-device/host-kit/host-file'; import path from 'node:path'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates'; @@ -198,6 +202,20 @@ export async function readIosSetting( */ const CONTAINER_MANAGER_METADATA_FILE = '.com.apple.mobile_container_manager.metadata.plist'; +/** + * The directories a fresh install creates in the data container. iOS does not recreate them on + * relaunch; without `tmp`, every URLSession download task fails until the app is reinstalled. + * The list matches the iOS 26.5 fresh-install layout; older runtimes and tvOS or visionOS + * simulators may differ, but an extra empty directory there is harmless. + */ +const FRESH_INSTALL_DATA_DIRECTORIES = [ + 'Documents', + 'Library/Caches', + 'Library/Preferences', + 'SystemData', + 'tmp', +]; + async function clearIosSimulatorAppState( device: DeviceInfo, app: string, @@ -234,6 +252,11 @@ async function clearIosSimulatorAppState( .filter((entry) => entry !== CONTAINER_MANAGER_METADATA_FILE) .map((entry) => removeHostPath(path.join(containerPath, entry))), ); + await Promise.all( + FRESH_INSTALL_DATA_DIRECTORIES.map((directory) => + ensureHostDirectory(path.join(containerPath, directory)), + ), + ); return { bundleId, containerPath }; } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 95e2777289..af6b3693f6 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -772,7 +772,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. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents. 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. 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). 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.