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
2 changes: 1 addition & 1 deletion docs/adr/0001-provider-first-integration-scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Request provider scoping is descriptor-driven inside the request router layer. A

Synchronous host-tool calls are intentionally not part of the provider seam. Any remaining sync Apple helper is local-only and must be converted before a remote/cloud provider can own that path.

Remaining generic Apple host-tool calls are intentionally local-only unless a new adapter creates pressure to promote them: perf process discovery/sampling (`mdfind`, `ps`), launch diagnostics (`plutil`, `otool`), local runner product build/signing (`swift`, `codesign`, sync `plutil`), local simulator UI launch (`open -a Simulator`), plist compatibility fallbacks, and fallback implementations behind semantic macOS providers. Provider-backed integration scenarios should avoid scripting those host commands for user workflows; a user-facing workflow that depends on one of them is naming pressure for a semantic provider method.
Remaining generic Apple host-tool calls are intentionally local-only unless a new adapter creates pressure to promote them: perf process discovery/sampling (`mdfind`, `ps`), launch diagnostics (`plutil`, `otool`), local runner product build/signing (`swift`, `codesign`, sync `plutil`), local simulator UI launch (`open -a "Device Hub"` or `open -a Simulator`), plist compatibility fallbacks, and fallback implementations behind semantic macOS providers. Provider-backed integration scenarios should avoid scripting those host commands for user workflows; a user-facing workflow that depends on one of them is naming pressure for a semantic provider method.

## Alternatives Considered

Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ test('apps.open resolves session device identifiers from open response', async (
app: 'Settings',
platform: 'ios',
relaunch: true,
noDeviceHub: true,
});

assert.equal(setup.calls.length, 1);
assert.equal(setup.calls[0]?.command, 'open');
assert.deepEqual(setup.calls[0]?.positionals, ['Settings']);
assert.equal(setup.calls[0]?.flags?.noDeviceHub, true);
assert.equal(result.identifiers.session, 'qa');
assert.equal(result.identifiers.deviceId, 'SIM-001');
assert.equal(result.identifiers.udid, 'SIM-001');
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
relaunch: options.relaunch,
shutdown: options.shutdown,
saveScript: options.saveScript,
noDeviceHub: options.noDeviceHub,
noRecord: options.noRecord,
backMode: options.backMode,
metroHost: options.metroHost,
Expand Down
2 changes: 2 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
launchArgs?: string[];
relaunch?: boolean;
saveScript?: boolean | string;
noDeviceHub?: boolean;
noRecord?: boolean;
runtime?: SessionRuntimeHints;
};
Expand Down Expand Up @@ -883,6 +884,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
noDeviceHub?: boolean;
noRecord?: boolean;
backMode?: BackMode;
metroHost?: string;
Expand Down
1 change: 1 addition & 0 deletions src/commands/cli-grammar/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const appCliReaders = {
launchArgs: flags.launchArgs,
relaunch: flags.relaunch,
saveScript: flags.saveScript,
noDeviceHub: flags.noDeviceHub,
noRecord: flags.noRecord,
}),
close: (positionals, flags) => ({
Expand Down
3 changes: 3 additions & 0 deletions src/commands/client-command-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export const clientCommandMetadata = [
),
relaunch: booleanField('Force relaunch.'),
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
noDeviceHub: booleanField(
'Skip Xcode Device Hub and use the standalone Simulator app when surfacing Apple simulators.',
),
noRecord: booleanField('Do not record this action.'),
}),
defineClientCommandMetadata('close', {
Expand Down
3 changes: 3 additions & 0 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type OpenAppOptions = {
launchArgs?: NonNullable<DaemonRequest['flags']>['launchArgs'];
out?: NonNullable<DaemonRequest['flags']>['out'];
saveScript?: NonNullable<DaemonRequest['flags']>['saveScript'];
noDeviceHub?: NonNullable<DaemonRequest['flags']>['noDeviceHub'];
relaunch?: boolean;
runtime?: DaemonRequest['runtime'];
meta?: Omit<NonNullable<DaemonRequest['meta']>, 'uploadedArtifactId' | 'clientArtifactPaths'>;
Expand Down Expand Up @@ -230,6 +231,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
launchArgs,
out,
saveScript,
noDeviceHub,
relaunch,
runtime,
meta,
Expand All @@ -252,6 +254,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
...(launchArgs !== undefined ? { launchArgs } : {}),
...(out !== undefined ? { out } : {}),
...(saveScript !== undefined ? { saveScript } : {}),
...(noDeviceHub !== undefined ? { noDeviceHub } : {}),
...(relaunch ? { relaunch: true } : {}),
},
...(runtime !== undefined ? { runtime } : {}),
Expand Down
17 changes: 17 additions & 0 deletions src/daemon/__tests__/device-ready.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ test('ensureDeviceReady caches successful simulator readiness checks', async ()
await ensureDeviceReady({ ...device });

expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1);
expect(mockEnsureBootedSimulator).toHaveBeenCalledWith(device, {
focusExisting: undefined,
preferStandalone: undefined,
});
});

test('ensureDeviceReady focuses cached simulator readiness checks when requested', async () => {
const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' };

await ensureDeviceReady(device);
await ensureDeviceReady({ ...device }, { focusExisting: true, noDeviceHub: true });

expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2);
expect(mockEnsureBootedSimulator).toHaveBeenLastCalledWith(
{ ...device },
{ focusExisting: true, preferStandalone: true },
);
});

test('ensureDeviceReady caches successful iOS physical device readiness checks', async () => {
Expand Down
17 changes: 14 additions & 3 deletions src/daemon/device-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ export const DEVICE_READY_CACHE_TTL_MS = 5_000;

const readyCache = new Map<string, number>();

export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
export type DeviceReadyOptions = {
focusExisting?: boolean;
noDeviceHub?: boolean;
};

export async function ensureDeviceReady(
device: DeviceInfo,
options: DeviceReadyOptions = {},
): Promise<void> {
const cacheKey = deviceReadyCacheKey(device);
const cachedUntil = readyCache.get(cacheKey);
if (cachedUntil !== undefined) {
if (cachedUntil > Date.now()) {
if (cachedUntil > Date.now() && !options.focusExisting) {
return;
}
readyCache.delete(cacheKey);
Expand All @@ -27,7 +35,10 @@ export async function ensureDeviceReady(device: DeviceInfo): Promise<void> {
if (device.platform === 'ios') {
if (device.kind === 'simulator') {
const { ensureBootedSimulator } = await import('../platforms/ios/simulator.ts');
await ensureBootedSimulator(device);
await ensureBootedSimulator(device, {
focusExisting: options.focusExisting,
preferStandalone: options.noDeviceHub,
});
markDeviceReady(cacheKey);
return;
}
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/handlers/session-open-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export async function prepareOpenCommandDetails(params: {
existingSession?: SessionState;
}): Promise<PreparedOpenCommandDetailsResult> {
const { req, sessionName, sessionStore, device, surface, openTarget, existingSession } = params;
await ensureDeviceReady(device);
await ensureDeviceReady(device, {
focusExisting: true,
noDeviceHub: req.flags?.noDeviceHub === true,
});
const { appBundleId, appName } = await resolvePreparedOpenIdentity({
device,
surface,
Expand Down
82 changes: 74 additions & 8 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ test('openIosApp custom scheme deep links on iOS devices require app bundle cont
);
});

test('ensureBootedSimulator opens Simulator app after cold boot', async () => {
test('ensureBootedSimulator opens Device Hub after cold boot when available', async () => {
let listCallCount = 0;
mockRunCmd.mockImplementation(async (cmd, args) => {
if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') {
Expand All @@ -432,23 +432,45 @@ test('ensureBootedSimulator opens Simulator app after cold boot', async () => {
if (cmd === 'xcrun' && args.join(' ') === 'simctl bootstatus sim-1 -b') {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (cmd === 'open' && args.join(' ') === '-a Simulator') {
if (cmd === 'open' && args.join(' ') === '-a Device Hub') {
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`);
});

await ensureBootedSimulator(IOS_TEST_SIMULATOR);
await ensureBootedSimulator(IOS_TEST_SIMULATOR, { focusExisting: true });

assert.equal(
mockRunCmd.mock.calls.some(
([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Simulator',
([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Device Hub',
),
true,
);
});

test('ensureBootedSimulator skips opening Simulator app when already booted', async () => {
test('openIosSimulatorApp falls back to Simulator when Device Hub is unavailable', async () => {
mockRunCmd.mockImplementation(async (cmd, args) => {
if (cmd === 'open' && args.join(' ') === '-a Device Hub') {
return { exitCode: 1, stdout: '', stderr: 'Unable to find application named Device Hub' };
}
if (cmd === 'open' && args.join(' ') === '-a Simulator') {
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`);
});

await openIosSimulatorApp();

assert.deepEqual(
mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, args.join(' ')]),
[
['open', '-a Device Hub'],
['open', '-a Simulator'],
],
);
});

test('ensureBootedSimulator opens Device Hub when already booted and available', async () => {
mockRunCmd.mockImplementation(async (cmd, args) => {
if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') {
return {
Expand All @@ -461,11 +483,20 @@ test('ensureBootedSimulator skips opening Simulator app when already booted', as
stderr: '',
};
}
if (cmd === 'open' && args.join(' ') === '-a Device Hub') {
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`);
});

await ensureBootedSimulator(IOS_TEST_SIMULATOR);
await ensureBootedSimulator(IOS_TEST_SIMULATOR, { focusExisting: true });

assert.equal(
mockRunCmd.mock.calls.some(
([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Device Hub',
),
true,
);
assert.equal(
mockRunCmd.mock.calls.some(
([cmd, args]) => cmd === 'open' && args.join(' ') === '-a Simulator',
Expand All @@ -474,6 +505,39 @@ test('ensureBootedSimulator skips opening Simulator app when already booted', as
);
});

test('ensureBootedSimulator honors standalone Simulator preference when already booted', async () => {
mockRunCmd.mockImplementation(async (cmd, args) => {
if (cmd === 'xcrun' && args.join(' ') === 'simctl list devices -j') {
return {
exitCode: 0,
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-6': [{ udid: 'sim-1', state: 'Booted' }],
},
}),
stderr: '',
};
}
if (cmd === 'open' && args.join(' ') === '-a Simulator') {
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`);
});

await ensureBootedSimulator(IOS_TEST_SIMULATOR, {
focusExisting: true,
preferStandalone: true,
});

assert.deepEqual(
mockRunCmd.mock.calls.map(([cmd, args]) => [cmd, args.join(' ')]),
[
['xcrun', 'simctl list devices -j'],
['open', '-a Simulator'],
],
);
});

test('shouldFallbackToRunnerForIosScreenshot detects removed devicectl subcommand output', () => {
const error = new AppError('COMMAND_FAILED', 'Failed to capture iOS screenshot', {
stderr: "error: Unknown option '--device'",
Expand Down Expand Up @@ -922,9 +986,11 @@ test('screenshotIos retries simulator capture timeouts and eventually succeeds',
'should retry screenshot command until success',
);
assert.equal(
logLines.filter((line) => line === '__OPEN__ -a Simulator').length,
logLines.filter(
(line) => line === '__OPEN__ -a Device Hub' || line === '__OPEN__ -a Simulator',
).length,
0,
'should not focus Simulator while retrying screenshots',
'should not focus simulator host app while retrying screenshots',
);
} finally {
process.env.PATH = previousPath;
Expand Down
42 changes: 34 additions & 8 deletions src/platforms/ios/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,50 @@ import {
import { buildSimctlArgs, buildSimctlArgsForDevice } from './simctl.ts';
import { runAppleToolCommand, runXcrun } from './tool-provider.ts';

const IOS_SIMULATOR_HOST_APPS = ['Device Hub', 'Simulator'] as const;
const IOS_SIMULATOR_STANDALONE_HOST_APPS = ['Simulator'] as const;

type OpenIosSimulatorAppOptions = {
preferStandalone?: boolean;
};

type EnsureBootedSimulatorOptions = {
focusExisting?: boolean;
preferStandalone?: boolean;
};

export function requireSimulatorDevice(device: DeviceInfo, command: string): void {
if (device.kind !== 'simulator') {
throw new AppError('UNSUPPORTED_OPERATION', `${command} is only supported on iOS simulators`);
}
}

export async function openIosSimulatorApp(): Promise<void> {
await runAppleToolCommand('open', ['-a', 'Simulator'], {
allowFailure: true,
timeoutMs: IOS_SIMULATOR_FOCUS_TIMEOUT_MS,
});
export async function openIosSimulatorApp(options: OpenIosSimulatorAppOptions = {}): Promise<void> {
const appNames = options.preferStandalone
? IOS_SIMULATOR_STANDALONE_HOST_APPS
: IOS_SIMULATOR_HOST_APPS;
for (const appName of appNames) {
const result = await runAppleToolCommand('open', ['-a', appName], {
allowFailure: true,
timeoutMs: IOS_SIMULATOR_FOCUS_TIMEOUT_MS,
});
if (result.exitCode === 0) return;
}
}

export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
export async function ensureBootedSimulator(
device: DeviceInfo,
options: EnsureBootedSimulatorOptions = {},
): Promise<void> {
if (device.kind !== 'simulator') return;

const state = await getSimulatorState(device);
if (state === 'Booted') return;
if (state === 'Booted') {
if (options.focusExisting) {
await openIosSimulatorApp({ preferStandalone: options.preferStandalone });
}
return;
}

const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
let bootResult:
Expand Down Expand Up @@ -151,7 +177,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
});
}

await openIosSimulatorApp();
await openIosSimulatorApp({ preferStandalone: options.preferStandalone });
}

export async function shutdownSimulator(device: DeviceInfo): Promise<{
Expand Down
11 changes: 11 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,15 @@ test('parseArgs recognizes explicit config file flag', () => {
assert.equal(parsed.flags.config, './agent-device.json');
});

test('parseArgs recognizes open Device Hub opt-out flag', () => {
const parsed = parseArgs(['open', 'settings', '--platform', 'ios', '--no-device-hub'], {
strictFlags: true,
});
assert.equal(parsed.command, 'open');
assert.equal(parsed.flags.platform, 'ios');
assert.equal(parsed.flags.noDeviceHub, true);
});

test('parseArgs recognizes session lock policy flag', () => {
const parsed = parseArgs(['snapshot', '--session-lock', 'strip'], { strictFlags: true });
assert.equal(parsed.command, 'snapshot');
Expand Down Expand Up @@ -1576,6 +1585,8 @@ test('open command usage documents surface and console log flags', () => {
assert.match(help, /macOS also supports --surface/);
assert.match(help, /--launch-console <path>/);
assert.match(help, /iOS simulator launch console/);
assert.match(help, /--no-device-hub/);
assert.match(help, /skip Xcode Device Hub/);
});

test('command usage shows record touch-overlay opt-out flag', () => {
Expand Down
Loading
Loading