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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ vi.mock('../simulator.ts', () => ({
if (device.kind !== 'simulator') throw new Error('simulator required');
},
}));
vi.mock('../apps-simctl.ts', () => ({ runSimctl }));
vi.mock('../simctl.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../simctl.ts')>()),
runSimctlForDevice: runSimctl,
}));

import { pushIosNotification } from '../app-device-io.ts';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { beforeEach, test, vi } from 'vitest';

const { mockRunSimctl } = vi.hoisted(() => ({ mockRunSimctl: vi.fn() }));

vi.mock('../apps-simctl.ts', () => ({ runSimctl: mockRunSimctl }));
vi.mock('../simctl.ts', async (importOriginal) => ({
...(await importOriginal<typeof import('../simctl.ts')>()),
runSimctlForDevice: mockRunSimctl,
}));

import {
detectSoleRunningIosSimulatorApp,
Expand Down
77 changes: 76 additions & 1 deletion packages/platform-apple/src/core/__tests__/simctl.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { buildSimctlArgs, buildSimctlArgsForDevice } from '../simctl.ts';
import {
buildSimctlArgs,
buildSimctlArgsForDevice,
readSimctlDevicesByRuntime,
readSimctlDeviceState,
scopeSimctlArgs,
scopeSimctlArgsForDevice,
} from '../simctl.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';

const IOS_SIMULATOR: DeviceInfo = {
Expand Down Expand Up @@ -48,3 +55,71 @@ test('buildSimctlArgsForDevice leaves non-simulator commands unchanged', () => {
]);
assert.deepEqual(args, ['simctl', 'bootstatus', 'sim-1', '-b']);
});

test('scopeSimctlArgs prefixes a trimmed simulator set and omits a blank one', () => {
assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' /tmp/set ' }), [
'--set',
'/tmp/set',
'list',
'devices',
'-j',
]);
assert.deepEqual(scopeSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath: ' ' }), [
'list',
'devices',
'-j',
]);
});

test('scopeSimctlArgsForDevice scopes simulators only', () => {
const scoped = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/tenant-c/simulator-set' };
assert.deepEqual(scopeSimctlArgsForDevice(scoped, ['shutdown', 'sim-1']), [
'--set',
'/tmp/tenant-c/simulator-set',
'shutdown',
'sim-1',
]);
assert.deepEqual(scopeSimctlArgsForDevice({ ...scoped, kind: 'device' }, ['shutdown', 'sim-1']), [
'shutdown',
'sim-1',
]);
});

const LISTING = JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
{ udid: 'sim-a', state: 'Shutdown', name: 'iPhone 16' },
],
'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [
{ udid: 'sim-b', state: 'Booted', name: 'iPhone 17' },
],
},
});

test('readSimctlDeviceState reports the listed state of the requested simulator', () => {
assert.equal(readSimctlDeviceState(LISTING, 'sim-b'), 'Booted');
assert.equal(readSimctlDeviceState(LISTING, 'sim-a'), 'Shutdown');
});

test('readSimctlDeviceState is null for an unlisted, unreadable, or empty listing', () => {
assert.equal(readSimctlDeviceState(LISTING, 'sim-missing'), null);
assert.equal(readSimctlDeviceState('not json', 'sim-b'), null);
assert.equal(readSimctlDeviceState('{}', 'sim-b'), null);
assert.equal(readSimctlDeviceState(JSON.stringify({ devices: { runtime: {} } }), 'sim-b'), null);
});

test('readSimctlDevicesByRuntime keys each device list by its runtime', () => {
const devicesByRuntime = readSimctlDevicesByRuntime(LISTING);
assert.deepEqual(
Object.entries(devicesByRuntime).map(([runtime, devices]) => [
runtime,
devices.map(({ udid }) => udid),
]),
[
['com.apple.CoreSimulator.SimRuntime.iOS-18-0', ['sim-a']],
['com.apple.CoreSimulator.SimRuntime.iOS-26-0', ['sim-b']],
],
);
assert.deepEqual(readSimctlDevicesByRuntime('{}'), {});
assert.throws(() => readSimctlDevicesByRuntime('not json'), SyntaxError);
});
8 changes: 4 additions & 4 deletions packages/platform-apple/src/core/app-device-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@agent-device/host-kit/host-file';
import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts';
import { readMacOsClipboardText, writeMacOsClipboardText } from '../os/macos/apps.ts';
import { runSimctl } from './apps-simctl.ts';
import { runSimctlForDevice } from './simctl.ts';

export async function readIosClipboardText(device: DeviceInfo): Promise<string> {
if (isMacOs(device)) {
Expand All @@ -17,7 +17,7 @@ export async function readIosClipboardText(device: DeviceInfo): Promise<string>
requireSimulatorDevice(device, 'clipboard');
await ensureBootedSimulator(device);
const result = requireExecSuccess(
await runSimctl(device, ['pbpaste', device.id], { allowFailure: true }),
await runSimctlForDevice(device, ['pbpaste', device.id], { allowFailure: true }),
'Failed to read iOS simulator clipboard',
);
return result.stdout.replaceAll('\r\n', '\n').replace(/\n$/, '');
Expand All @@ -31,7 +31,7 @@ export async function writeIosClipboardText(device: DeviceInfo, text: string): P
requireSimulatorDevice(device, 'clipboard');
await ensureBootedSimulator(device);
requireExecSuccess(
await runSimctl(device, ['pbcopy', device.id], {
await runSimctlForDevice(device, ['pbcopy', device.id], {
allowFailure: true,
stdin: text,
}),
Expand All @@ -52,7 +52,7 @@ export async function pushIosNotification(
const payloadPath = path.join(tempDir, 'payload.apns');
try {
await writeHostTextFile(payloadPath, `${JSON.stringify(payload)}\n`);
await runSimctl(device, ['push', device.id, bundleId, payloadPath], {
await runSimctlForDevice(device, ['push', device.id, bundleId, payloadPath], {
signal: options.signal,
});
} finally {
Expand Down
8 changes: 4 additions & 4 deletions packages/platform-apple/src/core/app-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { ensureBootedSimulator } from './simulator.ts';
import { runXcrun } from './tool-provider.ts';
import { closeMacOsApp, openMacOsApp } from '../os/macos/apps.ts';
import { resolveIosApp } from './app-resolution.ts';
import { runSimctl, simctlArgs } from './apps-simctl.ts';
import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts';

const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000;
const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE =
Expand Down Expand Up @@ -153,7 +153,7 @@ async function openIosSimulatorUrl(
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
}
await ensureBootedSimulator(device);
await runSimctl(device, ['openurl', device.id, url]);
await runSimctlForDevice(device, ['openurl', device.id, url]);
}

export async function openIosDevice(device: DeviceInfo): Promise<void> {
Expand Down Expand Up @@ -208,7 +208,7 @@ async function assertNotSystemSurfaceHost(bundleId: string): Promise<void> {
async function terminateIosSimulatorApp(device: DeviceInfo, bundleId: string): Promise<void> {
await assertNotSystemSurfaceHost(bundleId);
await ensureBootedSimulator(device);
const terminateArgs = simctlArgs(device, ['terminate', device.id, bundleId]);
const terminateArgs = buildSimctlArgsForDevice(device, ['terminate', device.id, bundleId]);
const result = await runXcrun(terminateArgs, {
allowFailure: true,
timeoutMs: IOS_SIMULATOR_TERMINATE_TIMEOUT_MS,
Expand Down Expand Up @@ -244,7 +244,7 @@ async function launchIosSimulatorApp(
});
}

const launchArgs = simctlArgs(
const launchArgs = buildSimctlArgsForDevice(
device,
buildIosSimulatorLaunchArgs(device.id, bundleId, options),
);
Expand Down
6 changes: 3 additions & 3 deletions packages/platform-apple/src/core/app-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { filterAppleAppsByBundlePrefix } from './app-filter.ts';
import { buildAppNotInstalledError } from './app-resolution-error.ts';
import { listMacApps, resolveMacOsApp } from '../os/macos/apps.ts';
import { runAppleToolCommand } from './tool-provider.ts';
import { runSimctl } from './apps-simctl.ts';
import { runSimctlForDevice } from './simctl.ts';
import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts';
import { createTtlMemo } from '@agent-device/kernel/ttl-memo';

Expand Down Expand Up @@ -147,7 +147,7 @@ export async function detectSoleRunningIosSimulatorApp(
}

async function listRunningIosSimulatorBundleIds(device: DeviceInfo): Promise<string[]> {
const result = await runSimctl(device, ['spawn', device.id, 'launchctl', 'list'], {
const result = await runSimctlForDevice(device, ['spawn', device.id, 'launchctl', 'list'], {
allowFailure: true,
timeoutMs: IOS_FOREGROUND_APP_PROBE_TIMEOUT_MS,
});
Expand Down Expand Up @@ -229,7 +229,7 @@ async function listSimulatorAppMetadata(
device: DeviceInfo,
options?: SimulatorAppListOptions,
): Promise<SimulatorAppMetadata[]> {
const result = await runSimctl(device, ['listapps', device.id], {
const result = await runSimctlForDevice(device, ['listapps', device.id], {
allowFailure: true,
timeoutMs: options?.timeoutMs,
});
Expand Down
29 changes: 17 additions & 12 deletions packages/platform-apple/src/core/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/maco
import { closeIosApp } from './app-launch.ts';
import { readIosTextSize, setIosTextSize } from './settings-text-size.ts';
import { resolveIosApp } from './app-resolution.ts';
import { runSimctl, simctlArgs } from './apps-simctl.ts';
import { buildSimctlArgsForDevice, runSimctlForDevice } from './simctl.ts';
import {
invalidateSimulatorStatusBarOverrideCache,
rememberClearedStatusBarOverrides,
Expand Down Expand Up @@ -78,7 +78,7 @@ export async function setIosSetting(
if (state.toLowerCase() !== 'clear') {
throw new AppError('INVALID_ARGS', 'settings reset-keychain only supports clear.');
}
await runSimctl(device, ['keychain', device.id, 'reset']);
await runSimctlForDevice(device, ['keychain', device.id, 'reset']);
return {
scope: 'simulator',
cleared: true,
Expand All @@ -89,14 +89,14 @@ export async function setIosSetting(
case 'wifi': {
const enabled = parseSettingState(state);
const mode = enabled ? 'active' : 'failed';
await runSimctl(device, ['status_bar', device.id, 'override', '--wifiMode', mode]);
await runSimctlForDevice(device, ['status_bar', device.id, 'override', '--wifiMode', mode]);
invalidateSimulatorStatusBarOverrideCache(device);
return;
}
case 'airplane': {
const enabled = parseSettingState(state);
if (enabled) {
await runSimctl(device, [
await runSimctlForDevice(device, [
'status_bar',
device.id,
'override',
Expand All @@ -115,23 +115,28 @@ export async function setIosSetting(
]);
invalidateSimulatorStatusBarOverrideCache(device);
} else {
await runSimctl(device, ['status_bar', device.id, 'clear']);
await runSimctlForDevice(device, ['status_bar', device.id, 'clear']);
rememberClearedStatusBarOverrides(device);
}
return;
}
case 'location': {
if (state.toLowerCase() === 'set') {
const { latitude, longitude } = requireLocationCoordinates(options);
await runSimctl(device, ['location', device.id, 'set', `${latitude},${longitude}`]);
await runSimctlForDevice(device, [
'location',
device.id,
'set',
`${latitude},${longitude}`,
]);
return { latitude, longitude };
}
const enabled = parseSettingState(state);
if (!appBundleId) {
throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
}
const action = enabled ? 'grant' : 'revoke';
await runSimctl(device, ['privacy', device.id, action, 'location', appBundleId]);
await runSimctlForDevice(device, ['privacy', device.id, action, 'location', appBundleId]);
return;
}
case 'faceid':
Expand All @@ -148,7 +153,7 @@ export async function setIosSetting(
}
case 'appearance': {
const target = await resolveIosAppearanceTarget(device, state);
await runSimctl(device, ['ui', device.id, 'appearance', target]);
await runSimctlForDevice(device, ['ui', device.id, 'appearance', target]);
return;
}
case 'text-size': {
Expand Down Expand Up @@ -201,7 +206,7 @@ async function clearIosSimulatorAppState(
await closeIosApp(device, bundleId);

const result = requireExecSuccess(
await runSimctl(device, ['get_app_container', device.id, bundleId, 'data'], {
await runSimctlForDevice(device, ['get_app_container', device.id, bundleId, 'data'], {
allowFailure: true,
}),
`simctl get_app_container failed for ${bundleId}`,
Expand Down Expand Up @@ -244,7 +249,7 @@ async function resolveIosAppearanceTarget(
if (action !== 'toggle') return action;

const currentResult = requireExecSuccess(
await runSimctl(device, ['ui', device.id, 'appearance'], {
await runSimctlForDevice(device, ['ui', device.id, 'appearance'], {
allowFailure: true,
}),
'Failed to read current iOS appearance',
Expand Down Expand Up @@ -291,7 +296,7 @@ async function runIosPrivacyCommand(
appBundleId: string,
): Promise<void> {
try {
await runSimctl(device, ['privacy', device.id, action, target, appBundleId]);
await runSimctlForDevice(device, ['privacy', device.id, action, target, appBundleId]);
} catch (error) {
if (!isPrivacyServiceRefusedError(error)) throw error;
throw privacyServiceRefusedError(device, action, target, appBundleId, error);
Expand Down Expand Up @@ -406,7 +411,7 @@ async function runIosBiometricSimctlCommand(
const failures: CommandAttemptFailure[] = [];

for (const args of attempts) {
const commandArgs = simctlArgs(device, args);
const commandArgs = buildSimctlArgsForDevice(device, args);
const result = await runXcrun(commandArgs, { allowFailure: true });
if (result.exitCode === 0) return;
failures.push({
Expand Down
23 changes: 0 additions & 23 deletions packages/platform-apple/src/core/apps-simctl.ts

This file was deleted.

9 changes: 8 additions & 1 deletion packages/platform-apple/src/core/physical-device-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
terminateIosDeviceApp,
} from './devicectl.ts';
import type { IosAppInfo, IosDeviceAppProcesses } from './app-info.ts';
import { isMissingAppErrorOutput } from './apps-simctl.ts';

export async function listCoreDeviceApps(
device: DeviceInfo,
Expand Down Expand Up @@ -61,3 +60,11 @@ export async function resolveCoreDeviceAppProcesses(
): Promise<IosDeviceAppProcesses> {
return await resolveIosDeviceAppProcesses(device, bundleId);
}

function isMissingAppErrorOutput(output: string): boolean {
return (
output.includes('not installed') ||
output.includes('not found') ||
output.includes('no such file')
);
}
Loading
Loading