From 17baa3084c465540de20ddc8249745e7894f4466 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Thu, 1 Oct 2020 05:32:07 -0700 Subject: [PATCH] Introduce emulatur launch dialog Summary: Changelog: Flipper can now launch iOS simulators by using `File > Launch Emulator...` In the Sandy designs the device selector dropdown no longer shows the option to launch an emulator. So added a button to app inspect and the main menu instead. I found it always a bit funny that we can launch android emulators, but not iOS emulators. Turns out that launching them is actually not very complex, so added capabilities to launch ios emulators Reviewed By: jknoxville Differential Revision: D24021737 fbshipit-source-id: c106cc2246921e008f9c808ebb811d8e333aa93b --- desktop/app/src/MenuBar.tsx | 7 ++ desktop/app/src/chrome/DevicesButton.tsx | 26 +---- desktop/app/src/devices/AndroidDevice.tsx | 24 ++++ desktop/app/src/dispatcher/iOSDevice.tsx | 48 +++++--- desktop/app/src/sandy-chrome/SandyApp.tsx | 2 +- .../{ => appinspect}/AppInspect.tsx | 19 ++- .../appinspect/LaunchEmulator.tsx | 108 ++++++++++++++++++ .../__tests__/LaunchEmulator.spec.tsx | 70 ++++++++++++ desktop/app/src/utils/renderReactRoot.tsx | 14 ++- 9 files changed, 273 insertions(+), 45 deletions(-) rename desktop/app/src/sandy-chrome/{ => appinspect}/AppInspect.tsx (80%) create mode 100644 desktop/app/src/sandy-chrome/appinspect/LaunchEmulator.tsx create mode 100644 desktop/app/src/sandy-chrome/appinspect/__tests__/LaunchEmulator.spec.tsx diff --git a/desktop/app/src/MenuBar.tsx b/desktop/app/src/MenuBar.tsx index 28e9093fd..107313168 100644 --- a/desktop/app/src/MenuBar.tsx +++ b/desktop/app/src/MenuBar.tsx @@ -28,6 +28,7 @@ import constants from './fb-stubs/constants'; import {Logger} from './fb-interfaces/Logger'; import {NormalizedMenuEntry, buildInMenuEntries} from 'flipper-plugin'; import {StyleGuide} from './sandy-chrome/StyleGuide'; +import {showEmulatorLauncher} from './sandy-chrome/appinspect/LaunchEmulator'; export type DefaultKeyboardAction = keyof typeof buildInMenuEntries; export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; @@ -226,6 +227,12 @@ function getTemplate( }); } const fileSubmenu: MenuItemConstructorOptions[] = [ + { + label: 'Launch Emulator...', + click() { + showEmulatorLauncher(store); + }, + }, { label: 'Preferences', accelerator: 'Cmd+,', diff --git a/desktop/app/src/chrome/DevicesButton.tsx b/desktop/app/src/chrome/DevicesButton.tsx index 1d5c7b742..64bc8c9e9 100644 --- a/desktop/app/src/chrome/DevicesButton.tsx +++ b/desktop/app/src/chrome/DevicesButton.tsx @@ -9,20 +9,19 @@ import {Button, styled} from 'flipper'; import {connect, ReactReduxContext} from 'react-redux'; -import {spawn} from 'child_process'; -import {dirname} from 'path'; + import {selectDevice, preferDevice} from '../reducers/connections'; import { setActiveSheet, ActiveSheet, ACTIVE_SHEET_JS_EMULATOR_LAUNCHER, } from '../reducers/application'; -import which from 'which'; import {showOpenDialog} from '../utils/exportData'; import BaseDevice from '../devices/BaseDevice'; import React, {Component} from 'react'; import {State} from '../reducers'; import GK from '../fb-stubs/GK'; +import {launchEmulator} from '../devices/AndroidDevice'; type StateFromProps = { selectedDevice: BaseDevice | null | undefined; @@ -44,25 +43,8 @@ const DropdownButton = styled(Button)({ }); class DevicesButton extends Component { - launchEmulator = (name: string) => { - // On Linux, you must run the emulator from the directory it's in because - // reasons ... - which('emulator') - .then((emulatorPath) => { - if (emulatorPath) { - const child = spawn(emulatorPath, [`@${name}`], { - detached: true, - cwd: dirname(emulatorPath), - }); - child.stderr.on('data', (data) => { - console.error(`Android emulator error: ${data}`); - }); - child.on('error', console.error); - } else { - throw new Error('Could not get emulator path'); - } - }) - .catch(console.error); + launchEmulator = async (name: string) => { + await launchEmulator(name); this.props.preferDevice(name); }; diff --git a/desktop/app/src/devices/AndroidDevice.tsx b/desktop/app/src/devices/AndroidDevice.tsx index 6d1063307..f6963205b 100644 --- a/desktop/app/src/devices/AndroidDevice.tsx +++ b/desktop/app/src/devices/AndroidDevice.tsx @@ -13,6 +13,9 @@ import {Priority} from 'adbkit-logcat'; import ArchivedDevice from './ArchivedDevice'; import {createWriteStream} from 'fs'; import type {LogLevel, DeviceType} from 'flipper-plugin'; +import which from 'which'; +import {spawn} from 'child_process'; +import {dirname} from 'path'; const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder'; @@ -189,3 +192,24 @@ export default class AndroidDevice extends BaseDevice { return destination; } } + +export async function launchEmulator(name: string) { + // On Linux, you must run the emulator from the directory it's in because + // reasons ... + return which('emulator') + .then((emulatorPath) => { + if (emulatorPath) { + const child = spawn(emulatorPath, [`@${name}`], { + detached: true, + cwd: dirname(emulatorPath), + }); + child.stderr.on('data', (data) => { + console.error(`Android emulator error: ${data}`); + }); + child.on('error', (e) => console.error(e)); + } else { + throw new Error('Could not get emulator path'); + } + }) + .catch((e) => console.error(e)); +} diff --git a/desktop/app/src/dispatcher/iOSDevice.tsx b/desktop/app/src/dispatcher/iOSDevice.tsx index 78906397d..fcd8ac917 100644 --- a/desktop/app/src/dispatcher/iOSDevice.tsx +++ b/desktop/app/src/dispatcher/iOSDevice.tsx @@ -30,7 +30,13 @@ type iOSSimulatorDevice = { udid: string; }; -type IOSDeviceParams = {udid: string; type: DeviceType; name: string}; +export type IOSDeviceParams = { + udid: string; + type: DeviceType; + name: string; + deviceTypeIdentifier?: string; + state?: string; +}; const exec = promisify(child_process.exec); @@ -79,7 +85,7 @@ if (typeof window !== 'undefined') { async function queryDevices(store: Store, logger: Logger): Promise { return Promise.all([ checkXcodeVersionMismatch(store), - getActiveSimulators().then((devices) => { + getSimulators(true).then((devices) => { processDevices(store, logger, devices, 'emulator'); }), getActiveDevices(store.getState().settingsState.idbPath).then( @@ -144,36 +150,50 @@ function processDevices( } } -function getActiveSimulators(): Promise> { - const deviceSetPath = process.env.DEVICE_SET_PATH +function getDeviceSetPath() { + return process.env.DEVICE_SET_PATH ? ['--set', process.env.DEVICE_SET_PATH] : []; +} + +export function getSimulators( + bootedOnly: boolean, +): Promise> { return promisify(execFile)( 'xcrun', - ['simctl', ...deviceSetPath, 'list', 'devices', '--json'], + ['simctl', ...getDeviceSetPath(), 'list', 'devices', '--json'], { encoding: 'utf8', }, ) .then(({stdout}) => JSON.parse(stdout).devices) .then((simulatorDevices: Array) => { - const simulators: Array = Object.values( - simulatorDevices, - ).reduce((acc: Array, cv) => acc.concat(cv), []); - + const simulators = Object.values(simulatorDevices).flat(); return simulators .filter( - (simulator) => simulator.state === 'Booted' && isAvailable(simulator), + (simulator) => + (!bootedOnly || simulator.state === 'Booted') && + isAvailable(simulator), ) .map((simulator) => { return { - udid: simulator.udid, + ...simulator, type: 'emulator', - name: simulator.name, } as IOSDeviceParams; }); }) - .catch((_) => []); + .catch((e) => { + console.error(e); + return Promise.resolve([]); + }); +} + +export function launchSimulator(udid: string): Promise { + return promisify(execFile)( + 'xcrun', + ['simctl', ...getDeviceSetPath(), 'boot', udid], + {encoding: 'utf8'}, + ); } function getActiveDevices(idbPath: string): Promise> { @@ -232,7 +252,7 @@ export async function getActiveDevicesAndSimulators( store: Store, ): Promise> { const activeDevices: Array> = await Promise.all([ - getActiveSimulators(), + getSimulators(true), getActiveDevices(store.getState().settingsState.idbPath), ]); const allDevices = activeDevices[0].concat(activeDevices[1]); diff --git a/desktop/app/src/sandy-chrome/SandyApp.tsx b/desktop/app/src/sandy-chrome/SandyApp.tsx index 6aa63483b..aeee664be 100644 --- a/desktop/app/src/sandy-chrome/SandyApp.tsx +++ b/desktop/app/src/sandy-chrome/SandyApp.tsx @@ -22,7 +22,7 @@ import {SandyContext} from './SandyContext'; import {ConsoleLogs} from '../chrome/ConsoleLogs'; import {setStaticView} from '../reducers/connections'; import {toggleLeftSidebarVisible} from '../reducers/application'; -import {AppInspect} from './AppInspect'; +import {AppInspect} from './appinspect/AppInspect'; export type ToplevelNavItem = 'appinspect' | 'flipperlogs' | undefined; export type ToplevelProps = { diff --git a/desktop/app/src/sandy-chrome/AppInspect.tsx b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx similarity index 80% rename from desktop/app/src/sandy-chrome/AppInspect.tsx rename to desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx index 6e30f2cce..f10577abd 100644 --- a/desktop/app/src/sandy-chrome/AppInspect.tsx +++ b/desktop/app/src/sandy-chrome/appinspect/AppInspect.tsx @@ -9,14 +9,17 @@ import React from 'react'; import {Button, Dropdown, Menu, Radio, Input} from 'antd'; -import {LeftSidebar, SidebarTitle, InfoIcon} from './LeftSidebar'; +import {LeftSidebar, SidebarTitle, InfoIcon} from '../LeftSidebar'; import { AppleOutlined, AndroidOutlined, SettingOutlined, + RocketOutlined, } from '@ant-design/icons'; -import {Layout, Link} from '../ui'; -import {theme} from './theme'; +import {Layout, Link} from '../../ui'; +import {theme} from '../theme'; +import {useStore as useReduxStore} from 'react-redux'; +import {showEmulatorLauncher} from './LaunchEmulator'; const appTooltip = ( <> @@ -30,6 +33,7 @@ const appTooltip = ( ); export function AppInspect() { + const store = useReduxStore(); return ( @@ -43,7 +47,14 @@ export function AppInspect() { + )), + ...iosEmulators.map((device) => ( + + )), + ]; + + return ( + + + {items.length ? items : } + + + ); +} diff --git a/desktop/app/src/sandy-chrome/appinspect/__tests__/LaunchEmulator.spec.tsx b/desktop/app/src/sandy-chrome/appinspect/__tests__/LaunchEmulator.spec.tsx new file mode 100644 index 000000000..da002b972 --- /dev/null +++ b/desktop/app/src/sandy-chrome/appinspect/__tests__/LaunchEmulator.spec.tsx @@ -0,0 +1,70 @@ +/** + * 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 React from 'react'; +import {fireEvent, render} from '@testing-library/react'; +import {Provider} from 'react-redux'; +import {createStore} from 'redux'; +import {LaunchEmulatorDialog} from '../LaunchEmulator'; + +import {rootReducer} from '../../../store'; +import {act} from 'react-dom/test-utils'; +import {sleep} from '../../../utils'; + +jest.mock('../../../devices/AndroidDevice', () => ({ + launchEmulator: jest.fn(() => Promise.resolve([])), +})); + +import {launchEmulator} from '../../../devices/AndroidDevice'; + +test('Can render and launch android apps', async () => { + const store = createStore(rootReducer); + const onClose = jest.fn(); + + const renderer = render( + + Promise.resolve([])} + /> + , + ); + + expect(await renderer.findByText(/No emulators/)).toMatchInlineSnapshot(` + + No emulators available + + `); + + act(() => { + store.dispatch({ + type: 'REGISTER_ANDROID_EMULATORS', + payload: ['emulator1', 'emulator2'], + }); + }); + + expect(await renderer.findAllByText(/emulator/)).toMatchInlineSnapshot(` + Array [ + + emulator1 + , + + emulator2 + , + ] + `); + + expect(onClose).not.toBeCalled(); + fireEvent.click(renderer.getByText('emulator2')); + await sleep(1000); + expect(onClose).toBeCalled(); + expect(launchEmulator).toBeCalledWith('emulator2'); +}); diff --git a/desktop/app/src/utils/renderReactRoot.tsx b/desktop/app/src/utils/renderReactRoot.tsx index 09699d2d1..8a1c64763 100644 --- a/desktop/app/src/utils/renderReactRoot.tsx +++ b/desktop/app/src/utils/renderReactRoot.tsx @@ -7,7 +7,10 @@ * @format */ +import React from 'react'; import {render, unmountComponentAtNode} from 'react-dom'; +import {Provider} from 'react-redux'; +import {Store} from '../reducers/'; /** * This utility creates a fresh react render hook, which is great to render elements imperatively, like opening dialogs. @@ -15,13 +18,16 @@ import {render, unmountComponentAtNode} from 'react-dom'; */ export function renderReactRoot( handler: (unmount: () => void) => React.ReactElement, + store: Store, ): void { const div = document.body.appendChild(document.createElement('div')); render( - handler(() => { - unmountComponentAtNode(div); - div.remove(); - }), + + {handler(() => { + unmountComponentAtNode(div); + div.remove(); + })} + , div, ); }