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, ); }