Start bridge abstraction

Summary: This is just an early start of centralising some ad-hoc logic we've got all over the place right now. Memoised do-we-have-idb calls with concatenated shell invocations. This gives us the opportunity to do a bit of testing, too.

Reviewed By: mweststrate

Differential Revision: D26694863

fbshipit-source-id: cd2b9883f90397802bbaae6030f7cb3881c565c2
This commit is contained in:
Pascal Hartig
2021-03-02 09:51:16 -08:00
committed by Facebook GitHub Bot
parent 43242557aa
commit 390f27a137
4 changed files with 183 additions and 61 deletions

View File

@@ -19,6 +19,7 @@ import path from 'path';
import {promisify} from 'util'; import {promisify} from 'util';
import {exec} from 'child_process'; import {exec} from 'child_process';
import {default as promiseTimeout} from '../utils/promiseTimeout'; import {default as promiseTimeout} from '../utils/promiseTimeout';
import {IOSBridge} from '../utils/IOSBridge';
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault'; type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
@@ -45,11 +46,16 @@ export default class IOSDevice extends BaseDevice {
private recordingProcess?: ChildProcess; private recordingProcess?: ChildProcess;
private recordingLocation?: string; private recordingLocation?: string;
constructor(serial: string, deviceType: DeviceType, title: string) { constructor(
iOSBridge: IOSBridge,
serial: string,
deviceType: DeviceType,
title: string,
) {
super(serial, deviceType, title, 'iOS'); super(serial, deviceType, title, 'iOS');
this.icon = 'mobile'; this.icon = 'mobile';
this.buffer = ''; this.buffer = '';
this.startLogListener(); this.startLogListener(iOSBridge);
} }
async screenshot(): Promise<Buffer> { async screenshot(): Promise<Buffer> {
@@ -81,47 +87,13 @@ export default class IOSDevice extends BaseDevice {
} }
} }
startLogListener(retries: number = 3) { startLogListener(iOSBridge: IOSBridge, retries: number = 3) {
if (retries === 0) { if (retries === 0) {
console.warn('Attaching iOS log listener continuously failed.'); console.warn('Attaching iOS log listener continuously failed.');
return; return;
} }
if (!this.log) { if (!this.log) {
const deviceSetPath = process.env.DEVICE_SET_PATH this.log = iOSBridge.startLogListener(this.serial);
? ['--set', process.env.DEVICE_SET_PATH]
: [];
const extraArgs = [
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
];
if (this.deviceType === 'physical') {
this.log = child_process.spawn(
'idb',
['log', '--udid', this.serial, '--', ...extraArgs],
{},
);
} else {
this.log = child_process.spawn(
'xcrun',
[
'simctl',
...deviceSetPath,
'spawn',
this.serial,
'log',
'stream',
...extraArgs,
],
{},
);
}
this.log.on('error', (err: Error) => { this.log.on('error', (err: Error) => {
console.error('iOS log tailer error', err); console.error('iOS log tailer error', err);
}); });
@@ -133,22 +105,22 @@ export default class IOSDevice extends BaseDevice {
this.log.on('exit', () => { this.log.on('exit', () => {
this.log = undefined; this.log = undefined;
}); });
}
try { try {
this.log.stdout this.log.stdout
.pipe(new StripLogPrefix()) .pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*')) .pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => { .on('data', (data: RawLogEntry) => {
const entry = IOSDevice.parseLogEntry(data); const entry = IOSDevice.parseLogEntry(data);
this.addLogEntry(entry); this.addLogEntry(entry);
}); });
} catch (e) { } catch (e) {
console.error('Could not parse iOS log stream.', e); console.error('Could not parse iOS log stream.', e);
// restart log stream // restart log stream
this.log.kill(); this.log.kill();
this.log = undefined; this.log = undefined;
this.startLogListener(retries - 1); this.startLogListener(iOSBridge, retries - 1);
}
} }
} }

View File

@@ -22,6 +22,7 @@ import {registerDeviceCallbackOnPlugins} from '../utils/onRegisterDevice';
import {addErrorNotification} from '../reducers/notifications'; import {addErrorNotification} from '../reducers/notifications';
import {getStaticPath} from '../utils/pathUtils'; import {getStaticPath} from '../utils/pathUtils';
import {destroyDevice} from '../reducers/connections'; import {destroyDevice} from '../reducers/connections';
import {IOSBridge, makeIOSBridge} from '../utils/IOSBridge';
type iOSSimulatorDevice = { type iOSSimulatorDevice = {
state: 'Booted' | 'Shutdown' | 'Shutting Down'; state: 'Booted' | 'Shutdown' | 'Shutting Down';
@@ -92,15 +93,19 @@ if (typeof window !== 'undefined') {
}); });
} }
async function queryDevices(store: Store, logger: Logger): Promise<any> { async function queryDevices(
store: Store,
logger: Logger,
iosBridge: IOSBridge,
): Promise<any> {
return Promise.all([ return Promise.all([
checkXcodeVersionMismatch(store), checkXcodeVersionMismatch(store),
getSimulators(store, true).then((devices) => { getSimulators(store, true).then((devices) => {
processDevices(store, logger, devices, 'emulator'); processDevices(store, logger, iosBridge, devices, 'emulator');
}), }),
getActiveDevices(store.getState().settingsState.idbPath).then( getActiveDevices(store.getState().settingsState.idbPath).then(
(devices: IOSDeviceParams[]) => { (devices: IOSDeviceParams[]) => {
processDevices(store, logger, devices, 'physical'); processDevices(store, logger, iosBridge, devices, 'physical');
}, },
), ),
]); ]);
@@ -109,6 +114,7 @@ async function queryDevices(store: Store, logger: Logger): Promise<any> {
function processDevices( function processDevices(
store: Store, store: Store,
logger: Logger, logger: Logger,
iosBridge: IOSBridge,
activeDevices: IOSDeviceParams[], activeDevices: IOSDeviceParams[],
type: 'physical' | 'emulator', type: 'physical' | 'emulator',
) { ) {
@@ -136,7 +142,7 @@ function processDevices(
name: name, name: name,
serial: udid, serial: udid,
}); });
const iOSDevice = new IOSDevice(udid, type, name); const iOSDevice = new IOSDevice(iosBridge, udid, type, name);
iOSDevice.loadDevicePlugins( iOSDevice.loadDevicePlugins(
store.getState().plugins.devicePlugins, store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins, store.getState().connections.enabledDevicePlugins,
@@ -225,12 +231,16 @@ function getActiveDevices(idbPath: string): Promise<Array<IOSDeviceParams>> {
}); });
} }
function queryDevicesForever(store: Store, logger: Logger) { function queryDevicesForever(
return queryDevices(store, logger) store: Store,
logger: Logger,
iosBridge: IOSBridge,
) {
return queryDevices(store, logger, iosBridge)
.then(() => { .then(() => {
// It's important to schedule the next check AFTER the current one has completed // It's important to schedule the next check AFTER the current one has completed
// to avoid simultaneous queries which can cause multiple user input prompts. // to avoid simultaneous queries which can cause multiple user input prompts.
setTimeout(() => queryDevicesForever(store, logger), 3000); setTimeout(() => queryDevicesForever(store, logger, iosBridge), 3000);
}) })
.catch((err) => { .catch((err) => {
console.warn('Failed to continuously query devices:', err); console.warn('Failed to continuously query devices:', err);
@@ -285,7 +295,9 @@ export default (store: Store, logger: Logger) => {
if (store.getState().settingsState.enablePhysicalIOS) { if (store.getState().settingsState.enablePhysicalIOS) {
startDevicePortForwarders(); startDevicePortForwarders();
} }
return queryDevicesForever(store, logger); return makeIOSBridge(
store.getState().settingsState.idbPath,
).then((iosBridge) => queryDevicesForever(store, logger, iosBridge));
} }
}); });
}; };

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import fs from 'fs';
import child_process from 'child_process';
export interface IOSBridge {
startLogListener: (
udid: string,
) => child_process.ChildProcessWithoutNullStreams;
}
async function isAvailable(idbPath: string): Promise<boolean> {
if (!idbPath) {
return false;
}
return fs.promises
.access(idbPath, fs.constants.X_OK)
.then((_) => true)
.catch((_) => false);
}
const LOG_EXTRA_ARGS = [
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
];
function idbStartLogListener(
idbPath: string,
udid: string,
): child_process.ChildProcessWithoutNullStreams {
return child_process.spawn(
idbPath,
['log', '--udid', udid, '--', ...LOG_EXTRA_ARGS],
{},
);
}
function xcrunStartLogListener(udid: string) {
const deviceSetPath = process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
return child_process.spawn(
'xcrun',
[
'simctl',
...deviceSetPath,
'spawn',
udid,
'log',
'stream',
...LOG_EXTRA_ARGS,
],
{},
);
}
export async function makeIOSBridge(idbPath: string): Promise<IOSBridge> {
if (await isAvailable(idbPath)) {
return {
startLogListener: idbStartLogListener.bind(null, idbPath),
};
}
return {
startLogListener: xcrunStartLogListener,
};
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {makeIOSBridge} from '../IOSBridge';
import childProcess from 'child_process';
import {mocked} from 'ts-jest/utils';
jest.mock('child_process');
const spawn = mocked(childProcess.spawn);
test('uses xcrun with no idb', async () => {
const ib = await makeIOSBridge('');
ib.startLogListener('deadbeef');
expect(spawn).toHaveBeenCalledWith(
'xcrun',
[
'simctl',
'spawn',
'deadbeef',
'log',
'stream',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
],
{},
);
});
test('uses idb when present', async () => {
const ib = await makeIOSBridge('/usr/local/bin/idb');
ib.startLogListener('deadbeef');
expect(spawn).toHaveBeenCalledWith(
'/usr/local/bin/idb',
[
'log',
'--udid',
'deadbeef',
'--',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--debug',
'--info',
],
{},
);
});