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
40 changes: 40 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,46 @@ test('devices filters Apple-family platform selectors', async () => {
}
});

test('devices omits internal appleOs from the public inventory projection', async () => {
const sessionStore = makeSessionStore();
mockListAndroidDevices.mockResolvedValue([]);
mockListAppleDevices.mockResolvedValue([
{
platform: 'ios' as const,
id: 'sim-1',
name: 'iPad Pro 11-inch (M4)',
kind: 'simulator' as const,
target: 'mobile' as const,
appleOs: 'ipados' as const,
booted: true,
simulatorSetPath: '/tmp/agent-device-sim-set',
},
]);

const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'devices',
positionals: [],
flags: { platform: 'ios' },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response?.ok).toBeTruthy();
if (response?.ok) {
const devices = response.data?.devices as Array<Record<string, unknown>> | undefined;
expect(devices).toHaveLength(1);
expect(devices?.[0]).not.toHaveProperty('appleOs');
expect(devices?.[0]).not.toHaveProperty('simulatorSetPath');
expect(devices?.[0]?.id).toBe('sim-1');
}
});

test('batch stops on first failing step with partial results', async () => {
const sessionStore = makeSessionStore();
const response = await handleSessionCommands({
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/handlers/session-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,11 @@ export async function handleSessionInventoryCommands(params: {
const filtered = req.flags?.target
? platformFiltered.filter((device) => (device.target ?? 'mobile') === req.flags?.target)
: platformFiltered;
// Keep appleOs internal-only for now: it is discovery groundwork and the
// public `devices` shape is not yet meant to expose it. Surfacing it (so
// agents can tell iPad from iPhone) should be a deliberate later change.
const publicDevices = filtered.map(
({ simulatorSetPath: _simulatorSetPath, ...device }) => device,
({ simulatorSetPath: _simulatorSetPath, appleOs: _appleOs, ...device }) => device,
);
return { ok: true, data: { devices: publicDevices } };
} catch (err) {
Expand Down
60 changes: 60 additions & 0 deletions src/platforms/ios/__tests__/apple-runner-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { resolveRunnerDestination, resolveRunnerPlatformName } from '../apple-runner-platform.ts';
import type { DeviceInfo } from '../../../utils/device.ts';

function iosSim(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
return {
platform: 'ios',
id: 'sim-1',
name: 'iPhone 16',
kind: 'simulator',
target: 'mobile',
booted: true,
...overrides,
};
}

test('resolveRunnerPlatformName prefers appleOs and maps iOS/iPadOS to the iOS profile', () => {
assert.equal(resolveRunnerPlatformName(iosSim({ appleOs: 'ios' })), 'iOS');
assert.equal(resolveRunnerPlatformName(iosSim({ name: 'iPad Pro', appleOs: 'ipados' })), 'iOS');
});

test('resolveRunnerPlatformName maps tvOS appleOs to the tvOS profile', () => {
assert.equal(
resolveRunnerPlatformName(iosSim({ name: 'Apple TV 4K', target: 'tv', appleOs: 'tvos' })),
'tvOS',
);
});

test('resolveRunnerPlatformName maps macOS appleOs to the macOS profile', () => {
const mac: DeviceInfo = {
platform: 'macos',
id: 'host-macos-local',
name: 'Studio Mac',
kind: 'device',
target: 'desktop',
appleOs: 'macos',
booted: true,
};
assert.equal(resolveRunnerPlatformName(mac), 'macOS');
});

test('resolveRunnerPlatformName falls back to target inference for legacy records', () => {
// No appleOs: behavior must match the pre-discriminant target inference.
assert.equal(resolveRunnerPlatformName(iosSim()), 'iOS');
assert.equal(resolveRunnerPlatformName(iosSim({ target: 'tv' })), 'tvOS');
});

test('iPadOS produces a byte-identical runner profile and destination to legacy iPad records', () => {
const legacyIpad = iosSim({ id: 'sim-ipad', name: 'iPad Pro', target: 'mobile' });
const taggedIpad = iosSim({
id: 'sim-ipad',
name: 'iPad Pro',
target: 'mobile',
appleOs: 'ipados',
});
assert.equal(resolveRunnerPlatformName(taggedIpad), resolveRunnerPlatformName(legacyIpad));
assert.equal(resolveRunnerDestination(taggedIpad), resolveRunnerDestination(legacyIpad));
assert.equal(resolveRunnerDestination(taggedIpad), 'platform=iOS Simulator,id=sim-ipad');
});
142 changes: 142 additions & 0 deletions src/platforms/ios/__tests__/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev
name: 'My iPhone',
kind: 'device',
target: 'mobile',
appleOs: 'ios',
booted: true,
},
{
Expand All @@ -113,11 +114,152 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev
name: 'Living Room Apple TV',
kind: 'device',
target: 'tv',
appleOs: 'tvos',
booted: true,
},
]);
});

test('parseXctracePhysicalAppleDevices tags physical iPads as iPadOS', () => {
const parsed = parseXctracePhysicalAppleDevices(
['== Devices ==', 'Studio iPad Pro [ipad-udid-1]'].join('\n'),
);
assert.deepEqual(parsed, [
{
platform: 'ios',
id: 'ipad-udid-1',
name: 'Studio iPad Pro',
kind: 'device',
target: 'mobile',
appleOs: 'ipados',
booted: true,
},
]);
});

test('listAppleDevices tags devicectl iPad product types as iPadOS', async () => {
mockRunCommand = async (_cmd, args) => {
if (args.join(' ') === 'simctl list devices -j') {
return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 };
}

if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') {
const jsonPath = String(args[4]);
await fs.writeFile(
jsonPath,
JSON.stringify({
result: {
devices: [
{
name: 'Field iPad',
hardwareProperties: { platform: 'iOS', udid: 'ipad-1', productType: 'iPad14,3' },
},
],
},
}),
'utf8',
);
return { stdout: '', stderr: '', exitCode: 0 };
}

if (args.join(' ') === 'xctrace list devices') {
return { stdout: '', stderr: '', exitCode: 0 };
}

throw new Error(`unexpected xcrun args: ${args.join(' ')}`);
};

const devices = await withMockedPlatform(
'darwin',
async () => await withMockedAppleTools(async () => await listAppleDevices()),
);

const iPad = devices.find((device) => device.id === 'ipad-1');
assert.equal(iPad?.target, 'mobile');
assert.equal(iPad?.appleOs, 'ipados');
});

test('listAppleDevices tags iPhone simulators and the host Mac with appleOs', async () => {
mockRunCommand = async (_cmd, args) => {
if (args.includes('simctl') && args.includes('list') && args.includes('devices')) {
return {
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
{ name: 'iPhone 16', udid: 'sim-iphone', state: 'Booted', isAvailable: true },
{
name: 'iPad Pro 11-inch (M4)',
udid: 'sim-ipad',
state: 'Shutdown',
isAvailable: true,
},
],
'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [
{ name: 'Apple TV 4K', udid: 'sim-tv', state: 'Shutdown', isAvailable: true },
],
},
}),
stderr: '',
exitCode: 0,
};
}
throw new Error(`unexpected xcrun args: ${args.join(' ')}`);
};

const devices = await withMockedPlatform(
'darwin',
async () =>
await withMockedAppleTools(
async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }),
),
);

const byId = new Map(devices.map((device) => [device.id, device.appleOs]));
assert.equal(byId.get('sim-iphone'), 'ios');
assert.equal(byId.get('sim-ipad'), 'ipados');
assert.equal(byId.get('sim-tv'), 'tvos');
assert.equal(byId.get('host-macos-local'), 'macos');
});

test('listAppleDevices tags renamed iPad simulators as iPadOS from deviceTypeIdentifier', async () => {
mockRunCommand = async (_cmd, args) => {
if (args.includes('simctl') && args.includes('list') && args.includes('devices')) {
return {
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
{
// Display name no longer mentions "iPad" (user-renamed), so the
// classification must come from deviceTypeIdentifier.
name: 'Work Tablet',
udid: 'sim-renamed-ipad',
state: 'Shutdown',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4',
},
],
},
}),
stderr: '',
exitCode: 0,
};
}
throw new Error(`unexpected xcrun args: ${args.join(' ')}`);
};

const devices = await withMockedPlatform(
'darwin',
async () =>
await withMockedAppleTools(
async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }),
),
);

const ipad = devices.find((device) => device.id === 'sim-renamed-ipad');
assert.equal(ipad?.target, 'mobile');
assert.equal(ipad?.appleOs, 'ipados');
});

test('listAppleDevices supplements unsupported devicectl entries with xctrace physical devices', async () => {
mockRunCommand = async (_cmd, args) => {
if (args.join(' ') === 'simctl list devices -j') {
Expand Down
4 changes: 3 additions & 1 deletion src/platforms/ios/apple-runner-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatfo
if (device.platform === 'macos') {
return 'macOS';
}
return resolveApplePlatformName(device.target);
// Prefer the stored Apple OS discriminant; fall back to target-based inference
// for legacy records that predate it. iPadOS maps to the iOS runner profile.
return resolveApplePlatformName(device.target, device.appleOs);
}

export function resolveRunnerSdkName(
Expand Down
36 changes: 33 additions & 3 deletions src/platforms/ios/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo, DeviceTarget } from '../../utils/device.ts';
import type { AppleOS, DeviceInfo, DeviceTarget } from '../../utils/device.ts';
import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts';
import { buildHostMacDevice } from '../macos/devices.ts';
import { buildSimctlArgs } from './simctl.ts';
Expand All @@ -12,6 +12,7 @@ export { createLocalAppleToolProvider, withAppleToolProvider } from './tool-prov

const IOS_DEVICECTL_LIST_TIMEOUT_MS = 8_000;
const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv)/i;
const APPLE_IPAD_PATTERN = /ipad/i;
const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i;
const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i;
const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const;
Expand All @@ -21,6 +22,10 @@ type SimctlDeviceRecord = {
udid: string;
state: string;
isAvailable: boolean;
// Stable simulator device-type id (e.g.
// com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4). Preferred over
// the user-editable display name when classifying the Apple OS.
deviceTypeIdentifier?: string;
};

type SimctlListDevicesPayload = {
Expand Down Expand Up @@ -89,6 +94,21 @@ function resolveAppleTargetFromLabel(value: string): DeviceTarget | null {
return null;
}

/**
* Derives the explicit Apple OS discriminant at discovery from the already
* resolved device target plus any available descriptors (product type and/or
* names). This is strictly additive metadata: it never widens discovery
* filters and never changes runner SDK/destination selection.
*
* tv targets map to tvOS; mobile targets split into iPadOS (when an iPad
* descriptor is present) and iOS otherwise.
*/
function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS {
if (target === 'tv') return 'tvos';
if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados';
return 'ios';
}

export function isAppleProductType(productType: string): boolean {
return APPLE_PRODUCT_TYPE_PATTERN.test(productType.trim());
}
Expand Down Expand Up @@ -185,12 +205,16 @@ function parseSimctlAppleDevices(
if (!isSupportedAppleRuntime(runtime)) continue;
for (const device of runtimes) {
if (!device.isAvailable) continue;
const target = resolveAppleTargetFromRuntime(runtime);
devices.push({
platform: 'ios',
id: device.udid,
name: device.name,
kind: 'simulator',
target: resolveAppleTargetFromRuntime(runtime),
target,
// Prefer the stable device-type id so a user-renamed iPad simulator is
// still tagged iPadOS; fall back to the display name when it is absent.
appleOs: resolveAppleOs(target, [device.deviceTypeIdentifier ?? '', device.name]),
booted: device.state === 'Booted',
...(simulatorSetPath ? { simulatorSetPath } : {}),
});
Expand All @@ -206,12 +230,17 @@ function mapDevicectlAppleDevices(payload: DevicectlListDevicesPayload): DeviceI
const id = device.hardwareProperties?.udid ?? device.identifier ?? '';
const name = device.name ?? device.deviceProperties?.name ?? id;
if (!id) continue;
const target = resolveAppleTargetFromDevicectlDevice(device);
devices.push({
platform: 'ios',
id,
name,
kind: 'device',
target: resolveAppleTargetFromDevicectlDevice(device),
target,
appleOs: resolveAppleOs(target, [
resolveDevicectlAppleProductType(device),
...resolveDevicectlAppleLabels(device),
]),
booted: true,
});
}
Expand Down Expand Up @@ -247,6 +276,7 @@ export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] {
name,
kind: 'device',
target,
appleOs: resolveAppleOs(target, [name]),
// xctrace lists currently connected devices in the "Devices" section.
// The "Devices Offline" section is excluded above, so treating these as
// booted preserves the existing physical-device selection semantics.
Expand Down
1 change: 1 addition & 0 deletions src/platforms/macos/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function buildHostMacDevice(): DeviceInfo {
name: os.hostname(),
kind: 'device',
target: 'desktop',
appleOs: 'macos',
booted: true,
};
}
Expand Down
Loading
Loading