diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index f1be1ca48..89dce2670 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -62,7 +62,7 @@ export type BootstrapErrorReason = | 'rotate_failed' | 'resolve_failed'; -function isIPhoneDevice(device: DeviceEntry): boolean { +function isSupportedIOSDevice(device: DeviceEntry): boolean { const platform = device.platform.trim().toLowerCase(); const deviceType = device.deviceType.trim().toLowerCase(); const model = device.model.trim().toLowerCase(); @@ -70,8 +70,13 @@ function isIPhoneDevice(device: DeviceEntry): boolean { // productType is present even on older CoreDevice versions. Prefer the // explicit platform/type fields when available, but retain productType as // a compatibility fallback. An explicit non-iOS platform always loses. - if (platform && platform !== 'ios') return false; - return deviceType === 'iphone' || model.startsWith('iphone'); + if (platform && platform !== 'ios' && platform !== 'ipados') return false; + return ( + deviceType === 'iphone' + || deviceType === 'ipad' + || model.startsWith('iphone') + || model.startsWith('ipad') + ); } function isAvailableDevice(device: Pick): boolean { @@ -87,7 +92,7 @@ function isAvailableDevice(device: Pick): bo } function defaultDeviceRank(device: DeviceEntry): number { - if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1; + if (!device.paired || !isSupportedIOSDevice(device) || !isAvailableDevice(device)) return -1; const state = device.state.trim().toLowerCase(); const transport = device.transport.trim().toLowerCase(); @@ -161,40 +166,40 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.paired && isIPhoneDevice(d)); - if (pairedIPhone) { + const pairedIOS = devices.find((d) => d.paired && isSupportedIOSDevice(d)); + if (pairedIOS) { return { ok: false, error: 'device_not_found', - detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`, + detail: `paired device ${pairedIOS.name} (${pairedIOS.identifier}) is ${pairedIOS.state}; connect it over USB and unlock it`, }; } - const firstIPhone = devices.find(isIPhoneDevice); - if (!firstIPhone) { + const firstIOS = devices.find(isSupportedIOSDevice); + if (!firstIOS) { return { ok: false, error: 'device_not_found', - detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA', + detail: 'no iPhone or iPad is connected; non-iOS devices are not eligible for iOS QA', }; } return { ok: false, error: 'no_paired_device', - detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`, + detail: `device ${firstIOS.name} (${firstIOS.identifier}) is ${firstIOS.state}; run \`xcrun devicectl manage pair --device ${firstIOS.identifier}\` and tap Trust on the device`, }; } - if (!isIPhoneDevice(target)) { + if (!isSupportedIOSDevice(target)) { return { ok: false, error: 'device_not_found', - detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`, + detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone or iPad`, }; } if (!target.paired) { return { ok: false, error: 'no_paired_device', - detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the iPhone`, + detail: `device ${target.name} (${target.identifier}) is ${target.state}; run \`xcrun devicectl manage pair --device ${target.identifier}\` and tap Trust on the device`, }; } if (!isAvailableDevice(target)) { diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts index 37af481be..55f5f2cc7 100644 --- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts +++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts @@ -158,7 +158,7 @@ describe('bootstrapTunnel', () => { if (r.ok) expect(r.tunnel.udid).toBe('IPHONE'); }); - test('fails clearly when only non-iPhone platforms are connected', async () => { + test('fails clearly when only non-iOS platforms are connected', async () => { const spawn = makeSpawn([ { argsMatch: /devicectl list devices/, @@ -175,13 +175,13 @@ describe('bootstrapTunnel', () => { }, }, { - identifier: 'IPAD', + identifier: 'WATCH', connectionProperties: { tunnelState: 'connected', pairingState: 'paired' }, - deviceProperties: { name: 'iPad' }, + deviceProperties: { name: 'Apple Watch' }, hardwareProperties: { - productType: 'iPad16,6', - platform: 'iOS', - deviceType: 'iPad', + productType: 'Watch7,5', + platform: 'watchOS', + deviceType: 'watch', }, }, ] }, @@ -193,11 +193,11 @@ describe('bootstrapTunnel', () => { expect(r.ok).toBe(false); if (!r.ok) { expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('no iPhone'); + expect(r.detail).toContain('no iPhone or iPad'); } }); - test('rejects an explicitly targeted non-iPhone device', async () => { + test('rejects an explicitly targeted non-iPhone/iPad device', async () => { const spawn = makeSpawn([ { argsMatch: /devicectl list devices/, @@ -220,7 +220,42 @@ describe('bootstrapTunnel', () => { expect(r.ok).toBe(false); if (!r.ok) { expect(r.error).toBe('device_not_found'); - expect(r.detail).toContain('not an iPhone'); + expect(r.detail).toContain('not an iPhone or iPad'); + } + }); + + test('accepts a physical iPad alongside iPhones', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl list devices/, + jsonOutput: { + result: { devices: [{ + identifier: 'IPAD-14-5', + connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' }, + deviceProperties: { name: 'Test iPad' }, + hardwareProperties: { + productType: 'iPad14,5', + platform: 'iOS', + deviceType: 'iPad', + }, + }] }, + }, + }, + { + argsMatch: /devicectl device process launch --device IPAD-14-5/, + exitCode: 0, + }, + ]); + + // Fast-fail at resolution stage so we verify device acceptance + const r = await bootstrapTunnel({ + bundleId: 'com.test', + spawnImpl: spawn, + resolveImpl: () => Promise.resolve(null), + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBe('resolve_failed'); } });