From 6da66a8abd53278cb81767db8a1cf73a64156b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 12:55:27 +0200 Subject: [PATCH 1/2] feat: add AppleOS discriminant to the device model (additive) Add an explicit, stored AppleOS ('ios' | 'ipados' | 'tvos' | 'watchos' | 'visionos' | 'macos') and an optional appleOs field on DeviceInfo so Apple operating systems are first-class instead of inferred late from DeviceTarget. - device.ts: add AppleOS type, optional DeviceInfo.appleOs, and make resolveApplePlatformName prefer device.appleOs while falling back to the existing target-based inference for legacy records. iOS and iPadOS both map to the single iOS runner profile; tvOS -> tvOS; macOS -> macOS. - Populate appleOs at discovery only (no widened filters): iPhone/iPod -> ios, iPad -> ipados, tvOS -> tvos, host Mac -> macos. - resolveRunnerPlatformName threads device.appleOs through. Non-breaking groundwork for the platforms/apple consolidation: records without appleOs resolve byte-identically, and no runner SDK/destination selection changes (iPad still resolves to the iOS runner profile). --- .../__tests__/apple-runner-platform.test.ts | 60 ++++++++++ src/platforms/ios/__tests__/devices.test.ts | 103 ++++++++++++++++++ src/platforms/ios/apple-runner-platform.ts | 4 +- src/platforms/ios/devices.ts | 30 ++++- src/platforms/macos/devices.ts | 1 + src/utils/__tests__/device.test.ts | 22 ++++ src/utils/device.ts | 26 +++++ 7 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 src/platforms/ios/__tests__/apple-runner-platform.test.ts diff --git a/src/platforms/ios/__tests__/apple-runner-platform.test.ts b/src/platforms/ios/__tests__/apple-runner-platform.test.ts new file mode 100644 index 000000000..b01809040 --- /dev/null +++ b/src/platforms/ios/__tests__/apple-runner-platform.test.ts @@ -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 { + 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'); +}); diff --git a/src/platforms/ios/__tests__/devices.test.ts b/src/platforms/ios/__tests__/devices.test.ts index dabe3ad21..0179bd9ec 100644 --- a/src/platforms/ios/__tests__/devices.test.ts +++ b/src/platforms/ios/__tests__/devices.test.ts @@ -105,6 +105,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev name: 'My iPhone', kind: 'device', target: 'mobile', + appleOs: 'ios', booted: true, }, { @@ -113,11 +114,113 @@ 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 supplements unsupported devicectl entries with xctrace physical devices', async () => { mockRunCommand = async (_cmd, args) => { if (args.join(' ') === 'simctl list devices -j') { diff --git a/src/platforms/ios/apple-runner-platform.ts b/src/platforms/ios/apple-runner-platform.ts index 88f2a7c57..ae77bd2e3 100644 --- a/src/platforms/ios/apple-runner-platform.ts +++ b/src/platforms/ios/apple-runner-platform.ts @@ -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( diff --git a/src/platforms/ios/devices.ts b/src/platforms/ios/devices.ts index e890abba8..7dc8704db 100644 --- a/src/platforms/ios/devices.ts +++ b/src/platforms/ios/devices.ts @@ -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'; @@ -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; @@ -89,6 +90,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()); } @@ -185,12 +201,14 @@ 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, + appleOs: resolveAppleOs(target, [device.name]), booted: device.state === 'Booted', ...(simulatorSetPath ? { simulatorSetPath } : {}), }); @@ -206,12 +224,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, }); } @@ -247,6 +270,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. diff --git a/src/platforms/macos/devices.ts b/src/platforms/macos/devices.ts index ec49a60d8..9b305d704 100644 --- a/src/platforms/macos/devices.ts +++ b/src/platforms/macos/devices.ts @@ -10,6 +10,7 @@ export function buildHostMacDevice(): DeviceInfo { name: os.hostname(), kind: 'device', target: 'desktop', + appleOs: 'macos', booted: true, }; } diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index fec02fc01..459ce1c9a 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -35,6 +35,28 @@ test('resolveApplePlatformName resolves tv and desktop targets', () => { assert.equal(resolveApplePlatformName(undefined), 'iOS'); }); +test('resolveApplePlatformName prefers the explicit appleOs over target inference', () => { + // iOS and iPadOS both resolve to the single iOS runner profile. + assert.equal(resolveApplePlatformName('mobile', 'ios'), 'iOS'); + assert.equal(resolveApplePlatformName('mobile', 'ipados'), 'iOS'); + assert.equal(resolveApplePlatformName('tv', 'tvos'), 'tvOS'); + assert.equal(resolveApplePlatformName('desktop', 'macos'), 'macOS'); +}); + +test('resolveApplePlatformName appleOs wins even when it disagrees with the legacy target', () => { + // A stored discriminant takes precedence; this guards the preference order. + assert.equal(resolveApplePlatformName('mobile', 'tvos'), 'tvOS'); + assert.equal(resolveApplePlatformName('tv', 'ios'), 'iOS'); +}); + +test('resolveApplePlatformName falls back to target inference for legacy records', () => { + // Records without appleOs (undefined) must resolve byte-identically to before. + assert.equal(resolveApplePlatformName('mobile', undefined), 'iOS'); + assert.equal(resolveApplePlatformName('tv', undefined), 'tvOS'); + assert.equal(resolveApplePlatformName('desktop', undefined), 'macOS'); + assert.equal(resolveApplePlatformName('macos', undefined), 'macOS'); +}); + test('resolveAppleSimulatorSetPathForSelector ignores simulator scoping for desktop selectors', () => { assert.equal( resolveAppleSimulatorSetPathForSelector({ diff --git a/src/utils/device.ts b/src/utils/device.ts index 2784566a0..f7a7676fa 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -1,6 +1,10 @@ import { AppError } from './errors.ts'; export type ApplePlatform = 'ios' | 'macos'; +// Explicit, stored Apple operating system. All six literals are reserved so the +// type is stable as platform support grows, but discovery only ever populates +// the four currently supported ones ('ios' | 'ipados' | 'tvos' | 'macos'). +export type AppleOS = 'ios' | 'ipados' | 'tvos' | 'watchos' | 'visionos' | 'macos'; export const PLATFORMS = ['ios', 'macos', 'android', 'linux', 'web'] as const; export type Platform = (typeof PLATFORMS)[number]; export const PLATFORM_SELECTORS = [...PLATFORMS, 'apple'] as const; @@ -16,6 +20,9 @@ export type DeviceInfo = { name: string; kind: DeviceKind; target?: DeviceTarget; + // Explicit Apple OS discriminant populated at discovery for Apple devices. + // Optional so legacy records (and non-Apple platforms) remain valid. + appleOs?: AppleOS; booted?: boolean; simulatorSetPath?: string; }; @@ -63,12 +70,31 @@ export function matchesPlatformSelector( export function resolveApplePlatformName( platformOrTarget: ApplePlatform | DeviceTarget | undefined, + appleOs?: AppleOS, ): 'iOS' | 'tvOS' | 'macOS' { + // Prefer the explicit, stored Apple OS when present; legacy records without + // it keep resolving through the existing target-based inference below. + if (appleOs) return resolveRunnerPlatformNameForAppleOs(appleOs); if (platformOrTarget === 'macos' || platformOrTarget === 'desktop') return 'macOS'; if (platformOrTarget === 'tv') return 'tvOS'; return 'iOS'; } +function resolveRunnerPlatformNameForAppleOs(appleOs: AppleOS): 'iOS' | 'tvOS' | 'macOS' { + switch (appleOs) { + case 'tvos': + return 'tvOS'; + case 'macos': + return 'macOS'; + // iOS and iPadOS share the single iOS runner profile/SDK. watchOS/visionOS + // are reserved in the type but never produced by discovery; defaulting them + // to the iOS profile keeps any future record on a valid runner profile + // without introducing a new one. + default: + return 'iOS'; + } +} + export function resolveAppleSimulatorSetPathForSelector(params: { simulatorSetPath?: string; platform?: PlatformSelector; From a74be4b7e5d7c092a483032dfd35627e161fe646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 13:37:14 +0200 Subject: [PATCH 2/2] fix: keep appleOs internal and derive ipados from simctl device type --- src/daemon/handlers/__tests__/session.test.ts | 40 +++++++++++++++++++ src/daemon/handlers/session-inventory.ts | 5 ++- src/platforms/ios/__tests__/devices.test.ts | 39 ++++++++++++++++++ src/platforms/ios/devices.ts | 8 +++- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 77d2164a6..11dd2af68 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -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> | 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({ diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index a63079ecd..d95f4e012 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -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) { diff --git a/src/platforms/ios/__tests__/devices.test.ts b/src/platforms/ios/__tests__/devices.test.ts index 0179bd9ec..405bbef6c 100644 --- a/src/platforms/ios/__tests__/devices.test.ts +++ b/src/platforms/ios/__tests__/devices.test.ts @@ -221,6 +221,45 @@ test('listAppleDevices tags iPhone simulators and the host Mac with appleOs', as 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') { diff --git a/src/platforms/ios/devices.ts b/src/platforms/ios/devices.ts index 7dc8704db..7c809940e 100644 --- a/src/platforms/ios/devices.ts +++ b/src/platforms/ios/devices.ts @@ -22,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 = { @@ -208,7 +212,9 @@ function parseSimctlAppleDevices( name: device.name, kind: 'simulator', target, - appleOs: resolveAppleOs(target, [device.name]), + // 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 } : {}), });