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
105 changes: 105 additions & 0 deletions src/platforms/apple/core/__tests__/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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')) {
Expand Down
61 changes: 42 additions & 19 deletions src/platforms/apple/core/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ type IosDeviceDiscoveryOptions = {
};

const XCTRACE_SECTION_HEADER_PATTERN = /^==\s*(.+?)\s*==$/;
const XCTRACE_DEVICE_LINE_PATTERN = /^(?<name>.+?)\s+\[(?<id>[^[\]]+)\]\s*$/;
const XCTRACE_DEVICE_LINE_PATTERN =
/^(?<name>.+?)\s+(?:\[(?<idBracket>[^[\]]+)\]|\((?<osVersion>[^)]+)\)\s+\((?<idParen>[^)]+)\))\s*$/;

function normalizeAppleDescriptor(value: string | undefined): string {
return (value ?? '').trim().toLowerCase();
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading