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
83 changes: 81 additions & 2 deletions packages/platform-apple/src/core/__tests__/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ const retryActual = await vi.importActual<typeof import('@agent-device/host-kit/
);
const simulatorActual = await vi.importActual<typeof import('../simulator.ts')>('../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';
Expand Down Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions packages/platform-apple/src/core/__tests__/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 11 additions & 0 deletions packages/platform-apple/src/core/__tests__/simctl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
buildSimctlArgsForAddress,
buildSimctlArgsForDevice,
readSimctlContainerPath,
readSimctlDevicesByRuntime,
readSimctlDeviceState,
scopeSimctlArgsForDevice,
Expand Down Expand Up @@ -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 <bundle> 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);
});
36 changes: 29 additions & 7 deletions packages/platform-apple/src/core/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions packages/platform-apple/src/core/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions packages/platform-apple/src/core/simctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion src/commands/capture/settings.ts
Original file line number Diff line number Diff line change
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. 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.`,
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. 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 <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
Loading
Loading