fix(ios-qa): allow physical iPad devices in tunnel bootstrap (#2743)

This commit is contained in:
loulanyue committed 2026-09-02 20:46:24 +08:00
1 parent 0d1bd5616c
commit d6d43934ef
2 files changed
+63 -23

No files matched your search

+19 -14
View File
@@ -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<DeviceEntry, 'state' | 'transport'>): boolean {
@@ -87,7 +92,7 @@ function isAvailableDevice(device: Pick<DeviceEntry, 'state' | 'transport'>): 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<Bootstrap
if (opts.udid) {
return { ok: false, error: 'device_not_found', detail: opts.udid };
}
const pairedIPhone = devices.find((d) => 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)) {
+44 -9
View File
@@ -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');
}
});