diff --git a/src/platforms/apple/core/__tests__/devices.test.ts b/src/platforms/apple/core/__tests__/devices.test.ts index c6dceb4ed..37493d411 100644 --- a/src/platforms/apple/core/__tests__/devices.test.ts +++ b/src/platforms/apple/core/__tests__/devices.test.ts @@ -231,6 +231,74 @@ test('parseXctracePhysicalAppleDevices tags Apple Vision devices as visionOS', ( ]); }); +test('parseXctracePhysicalAppleDevices parses the parenthesized physical device format', () => { + const parsed = parseXctracePhysicalAppleDevices( + [ + '== Devices ==', + 'iPhone 8 Plus (16.7.16) (00008020-001C2D2234567890)', + 'Studio iPad Pro (17.0) (ipad-udid-1)', + 'Living Room Apple TV (16.0) (tv-udid-1)', + ].join('\n'), + ); + + assert.deepEqual(parsed, [ + { + platform: 'apple', + id: '00008020-001C2D2234567890', + name: 'iPhone 8 Plus', + kind: 'device', + target: 'mobile', + appleOs: 'ios', + booted: true, + }, + { + platform: 'apple', + id: 'ipad-udid-1', + name: 'Studio iPad Pro', + kind: 'device', + target: 'mobile', + appleOs: 'ipados', + booted: true, + }, + { + platform: 'apple', + id: 'tv-udid-1', + name: 'Living Room Apple TV', + kind: 'device', + target: 'tv', + appleOs: 'tvos', + booted: true, + }, + ]); +}); + +test('parseXctracePhysicalAppleDevices preserves parentheses in bracket-format names', () => { + const parsed = parseXctracePhysicalAppleDevices( + ['== Devices ==', "Alex's (iPhone) [alex-udid]", 'Office iPhone (2) [office-udid]'].join('\n'), + ); + + assert.deepEqual(parsed, [ + { + platform: 'apple', + id: 'alex-udid', + name: "Alex's (iPhone)", + kind: 'device', + target: 'mobile', + appleOs: 'ios', + booted: true, + }, + { + platform: 'apple', + id: 'office-udid', + name: 'Office iPhone (2)', + kind: 'device', + target: 'mobile', + appleOs: 'ios', + booted: true, + }, + ]); +}); + test('listAppleDevices tags devicectl iPad product types as iPadOS', async () => { mockRunCommand = async (_cmd, args) => { if (args.join(' ') === 'simctl list devices -j') { @@ -499,6 +567,43 @@ test('listAppleDevices prefers devicectl metadata when xctrace reports the same assert.equal(physicalDevices[0]?.name, 'Primary Name'); }); +test('listAppleDevices falls back to xctrace parenthesized devices when devicectl reports none', 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: [] } }), 'utf8'); + return { stdout: '', stderr: '', exitCode: 0 }; + } + + if (args.join(' ') === 'xctrace list devices') { + return { + stdout: ['== Devices ==', 'iPhone 8 Plus (16.7.16) (00008020-001C2D2234567890)'].join('\n'), + stderr: '', + exitCode: 0, + }; + } + + throw new Error(`unexpected xcrun args: ${args.join(' ')}`); + }; + + const devices = await withMockedPlatform( + 'darwin', + async () => await withMockedAppleTools(async () => await listAppleDevices()), + ); + + const physicalDevices = devices.filter( + (device) => device.kind === 'device' && isIosFamily(device), + ); + + assert.equal(physicalDevices.length, 1); + assert.equal(physicalDevices[0]?.id, '00008020-001C2D2234567890'); + assert.equal(physicalDevices[0]?.name, 'iPhone 8 Plus'); +}); + test('listAppleDevices keeps physical discovery disabled for simulator-set scoped runs', async () => { mockRunCommand = async (_cmd, args) => { if (args.includes('simctl') && args.includes('list') && args.includes('devices')) { diff --git a/src/platforms/apple/core/devices.ts b/src/platforms/apple/core/devices.ts index faaa7c281..0368d30d2 100644 --- a/src/platforms/apple/core/devices.ts +++ b/src/platforms/apple/core/devices.ts @@ -58,7 +58,8 @@ type IosDeviceDiscoveryOptions = { }; const XCTRACE_SECTION_HEADER_PATTERN = /^==\s*(.+?)\s*==$/; -const XCTRACE_DEVICE_LINE_PATTERN = /^(?.+?)\s+\[(?[^[\]]+)\]\s*$/; +const XCTRACE_DEVICE_LINE_PATTERN = + /^(?.+?)\s+(?:\[(?[^[\]]+)\]|\((?[^)]+)\)\s+\((?[^)]+)\))\s*$/; function normalizeAppleDescriptor(value: string | undefined): string { return (value ?? '').trim().toLowerCase(); @@ -254,6 +255,42 @@ function mapDevicectlAppleDevices(payload: DevicectlListDevicesPayload): DeviceI return devices; } +function parseXctraceDeviceLine( + line: string, +): { name: string; id: string; osVersion?: string } | null { + const match = XCTRACE_DEVICE_LINE_PATTERN.exec(line); + if (!match?.groups) return null; + + const name = match.groups.name?.trim() ?? ''; + const id = match.groups.idBracket?.trim() ?? match.groups.idParen?.trim() ?? ''; + const osVersion = match.groups.osVersion?.trim(); + if (!name || !id) return null; + + return { name, id, osVersion }; +} + +function buildXctracePhysicalDevice( + name: string, + id: string, + osVersion: string | undefined, +): DeviceInfo | null { + const target = resolveAppleTargetFromLabel(name); + if (!target) return null; + + return { + platform: 'apple', + id, + name, + kind: 'device', + target, + appleOs: resolveAppleOs(target, osVersion ? [name, osVersion] : [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. + booted: true, + }; +} + export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] { const devices: DeviceInfo[] = []; let section: string | null = null; @@ -270,25 +307,11 @@ export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] { if (section !== 'Devices') continue; - const deviceMatch = XCTRACE_DEVICE_LINE_PATTERN.exec(line); - const id = deviceMatch?.groups?.id?.trim() ?? ''; - const name = deviceMatch?.groups?.name?.trim() ?? ''; - if (!id || !name) continue; - const target = resolveAppleTargetFromLabel(name); - if (!target) continue; + const parsedLine = parseXctraceDeviceLine(line); + if (!parsedLine) continue; - devices.push({ - platform: 'apple', - id, - 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. - booted: true, - }); + const device = buildXctracePhysicalDevice(parsedLine.name, parsedLine.id, parsedLine.osVersion); + if (device) devices.push(device); } return devices;