Make sure disconnected devices / apps can be imported and exported

Summary:
It should be possible to exported disconnected devices, so that flipper traces / support form reports can be created from them. This diff introduces this functionality. Support for plugins with custom export logic is introduced in a later diff.

Issues fixed in this diff:
- don't try to take a screenshot for a disconnected device (this would hang forever)
- device plugins were always exported, regardless whether the user did select them or not
- sandy plugins were never part of exported disconnected clients
- increased the amount of data exported for device logs to ~10 MB. This makes more sense now as the logs will no longer be included in all cases
- fixed issue where are plugins would appear to be enabled after the client disconnected (this bug is the result of some unfortunate naming of `isArchived` vs `isConnected` semantics. Will clean up those names in a later diff.

Changelog: It is now possible to create a Flipper trace for disconnected devices and apps

Reviewed By: nikoant

Differential Revision: D26250894

fbshipit-source-id: 4dd0ec0cb152b1a8f649c31913e80efc25bcc5dd
This commit is contained in:
Michel Weststrate
2021-02-09 04:12:09 -08:00
committed by Facebook GitHub Bot
parent 8bc1b953c2
commit ff7997b3fa
11 changed files with 65 additions and 29 deletions

View File

@@ -93,7 +93,10 @@ export default class AndroidDevice extends BaseDevice {
this.adb.shell(this.serial, shellCommand); this.adb.shell(this.serial, shellCommand);
} }
screenshot(): Promise<Buffer> { async screenshot(): Promise<Buffer> {
if (this.isArchived) {
return Buffer.from([]);
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.adb.screencap(this.serial).then((stream) => { this.adb.screencap(this.serial).then((stream) => {
const chunks: Array<Buffer> = []; const chunks: Array<Buffer> = [];
@@ -108,6 +111,9 @@ export default class AndroidDevice extends BaseDevice {
} }
async screenCaptureAvailable(): Promise<boolean> { async screenCaptureAvailable(): Promise<boolean> {
if (this.isArchived) {
return false;
}
try { try {
await this.executeShell( await this.executeShell(
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`, `[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,

View File

@@ -99,11 +99,15 @@ export default class BaseDevice {
async exportState( async exportState(
idler: Idler, idler: Idler,
onStatusMessage: (msg: string) => void, onStatusMessage: (msg: string) => void,
selectedPlugins: string[],
): Promise<Record<string, any>> { ): Promise<Record<string, any>> {
const pluginStates: Record<string, any> = {}; const pluginStates: Record<string, any> = {};
for (const instance of this.sandyPluginStates.values()) { for (const instance of this.sandyPluginStates.values()) {
if (instance.isPersistable()) { if (
selectedPlugins.includes(instance.definition.id) &&
instance.isPersistable()
) {
pluginStates[instance.definition.id] = await instance.exportState( pluginStates[instance.definition.id] = await instance.exportState(
idler, idler,
onStatusMessage, onStatusMessage,

View File

@@ -52,7 +52,10 @@ export default class IOSDevice extends BaseDevice {
this.startLogListener(); this.startLogListener();
} }
screenshot(): Promise<Buffer> { async screenshot(): Promise<Buffer> {
if (this.isArchived) {
return Buffer.from([]);
}
const tmpImageName = uuid() + '.png'; const tmpImageName = uuid() + '.png';
const tmpDirectory = (electron.app || electron.remote.app).getPath('temp'); const tmpDirectory = (electron.app || electron.remote.app).getPath('temp');
const tmpFilePath = path.join(tmpDirectory, tmpImageName); const tmpFilePath = path.join(tmpDirectory, tmpImageName);
@@ -189,7 +192,7 @@ export default class IOSDevice extends BaseDevice {
} }
async screenCaptureAvailable() { async screenCaptureAvailable() {
return this.deviceType === 'emulator'; return this.deviceType === 'emulator' && !this.isArchived;
} }
async startScreenCapture(destination: string) { async startScreenCapture(destination: string) {

View File

@@ -18,7 +18,7 @@ import {
DownloadOutlined, DownloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {Glyph, Layout, styled} from '../../ui'; import {Glyph, Layout, styled} from '../../ui';
import {theme, NUX, Tracked} from 'flipper-plugin'; import {theme, NUX, Tracked, useValue} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore'; import {useDispatch, useStore} from '../../utils/useStore';
import { import {
computePluginLists, computePluginLists,
@@ -85,7 +85,7 @@ export const PluginList = memo(function PluginList({
connections.userStarredPlugins, connections.userStarredPlugins,
pluginsChanged, pluginsChanged,
]); ]);
const isArchived = !!activeDevice?.isArchived; const isArchived = useValue(activeDevice?.archivedState, false);
const annotatedDownloadablePlugins = useMemoize< const annotatedDownloadablePlugins = useMemoize<
[ [

View File

@@ -20,7 +20,7 @@ import {
import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin'; import {FlipperPlugin, FlipperDevicePlugin} from '../../plugin';
import {Notification} from '../../plugin'; import {Notification} from '../../plugin';
import {default as Client, ClientExport} from '../../Client'; import {default as Client, ClientExport} from '../../Client';
import {State as PluginsState} from '../../reducers/plugins'; import {selectedPlugins, State as PluginsState} from '../../reducers/plugins';
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import { import {
TestUtils, TestUtils,
@@ -1321,13 +1321,19 @@ test('Sandy device plugins are exported / imported properly', async () => {
const device2 = store.getState().connections.devices[1]; const device2 = store.getState().connections.devices[1];
expect(device2).not.toBeFalsy(); expect(device2).not.toBeFalsy();
expect(device2).not.toBe(device); expect(device2).not.toBe(device);
expect(device2.devicePlugins).toEqual([TestPlugin.id]); expect(device2.devicePlugins).toEqual([sandyDeviceTestPlugin.id]);
const {counter} = device2.sandyPluginStates.get(TestPlugin.id)?.instanceApi; const {counter} = device2.sandyPluginStates.get(
sandyDeviceTestPlugin.id,
)?.instanceApi;
counter.set(counter.get() + 1); counter.set(counter.get() + 1);
expect( expect(
(await device.exportState(testIdler, testOnStatusMessage))[TestPlugin.id], (
await device.exportState(testIdler, testOnStatusMessage, [
sandyDeviceTestPlugin.id,
])
)[sandyDeviceTestPlugin.id],
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
Object { Object {
"counter": 0, "counter": 0,
@@ -1336,8 +1342,11 @@ test('Sandy device plugins are exported / imported properly', async () => {
}, },
} }
`); `);
expect(await device2.exportState(testIdler, testOnStatusMessage)) expect(
.toMatchInlineSnapshot(` await device2.exportState(testIdler, testOnStatusMessage, [
sandyDeviceTestPlugin.id,
]),
).toMatchInlineSnapshot(`
Object { Object {
"TestPlugin": Object { "TestPlugin": Object {
"counter": 4, "counter": 4,
@@ -1358,6 +1367,7 @@ test('Sandy device plugins with custom export are export properly', async () =>
.get(sandyDeviceTestPlugin.id) .get(sandyDeviceTestPlugin.id)
?.instanceApi.enableCustomExport(); ?.instanceApi.enableCustomExport();
store.dispatch(selectedPlugins([sandyDeviceTestPlugin.id]));
const storeExport = await exportStore(store); const storeExport = await exportStore(store);
expect(storeExport.exportStoreData.device!.pluginStates).toEqual({ expect(storeExport.exportStoreData.device!.pluginStates).toEqual({
[sandyDeviceTestPlugin.id]: {customExport: true}, [sandyDeviceTestPlugin.id]: {customExport: true},
@@ -1522,8 +1532,9 @@ test('Sandy plugins with complex data are imported / exported correctly', async
}, },
); );
const {store} = await createMockFlipperWithPlugin(plugin); const {store, client} = await createMockFlipperWithPlugin(plugin);
client.disconnect(); // lets make sure we can still export disconnected clients
const data = await exportStore(store); const data = await exportStore(store);
expect(Object.values(data.exportStoreData.pluginStates2)).toMatchObject([ expect(Object.values(data.exportStoreData.pluginStates2)).toMatchObject([
{ {
@@ -1591,6 +1602,7 @@ test('Sandy device plugins with complex data are imported / exported correctly'
); );
const {store} = await createMockFlipperWithPlugin(deviceplugin); const {store} = await createMockFlipperWithPlugin(deviceplugin);
store.dispatch(selectedPlugins([deviceplugin.id]));
const data = await exportStore(store); const data = await exportStore(store);
expect(data.exportStoreData.device?.pluginStates).toMatchObject({ expect(data.exportStoreData.device?.pluginStates).toMatchObject({

View File

@@ -431,10 +431,14 @@ export async function processStore(
statusUpdate = () => {}; statusUpdate = () => {};
} }
statusUpdate('Capturing screenshot...'); statusUpdate('Capturing screenshot...');
const deviceScreenshot = await capture(device).catch((e) => { const deviceScreenshot = device.isArchived
console.warn('Failed to capture device screenshot when exporting. ' + e); ? null
return null; : await capture(device).catch((e) => {
}); console.warn(
'Failed to capture device screenshot when exporting. ' + e,
);
return null;
});
const processedClients = processClients(clients, serial, statusUpdate); const processedClients = processClients(clients, serial, statusUpdate);
const processedPluginStates = processPluginStates({ const processedPluginStates = processPluginStates({
clients: processedClients, clients: processedClients,
@@ -461,7 +465,7 @@ export async function processStore(
); );
const devicePluginStates = await makeObjectSerializable( const devicePluginStates = await makeObjectSerializable(
await device.exportState(idler, statusUpdate), await device.exportState(idler, statusUpdate, selectedPlugins),
idler, idler,
statusUpdate, statusUpdate,
'Serializing device plugins', 'Serializing device plugins',
@@ -610,11 +614,7 @@ export function determinePluginsToProcess(
const selectedPlugins = plugins.selectedPlugins; const selectedPlugins = plugins.selectedPlugins;
for (const client of clients) { for (const client of clients) {
if ( if (!selectedDevice || client.query.device_id !== selectedDevice.serial) {
!selectedDevice ||
selectedDevice.isArchived ||
client.query.device_id !== selectedDevice.serial
) {
continue; continue;
} }
const selectedFilteredPlugins = client const selectedFilteredPlugins = client

View File

@@ -27,6 +27,7 @@ import type {
PluginDetails, PluginDetails,
} from 'flipper-plugin-lib'; } from 'flipper-plugin-lib';
import {filterNewestVersionOfEachPlugin} from '../dispatcher/plugins'; import {filterNewestVersionOfEachPlugin} from '../dispatcher/plugins';
import ArchivedDevice from '../devices/ArchivedDevice';
export const defaultEnabledBackgroundPlugins = ['Navigation']; // The navigation plugin is enabled always, to make sure the navigation features works export const defaultEnabledBackgroundPlugins = ['Navigation']; // The navigation plugin is enabled always, to make sure the navigation features works
@@ -300,11 +301,11 @@ function getFavoritePlugins(
starredPlugins: undefined | string[], starredPlugins: undefined | string[],
returnFavoredPlugins: boolean, // if false, unfavoried plugins are returned returnFavoredPlugins: boolean, // if false, unfavoried plugins are returned
): PluginDefinition[] { ): PluginDefinition[] {
if (device.isArchived) { if (device instanceof ArchivedDevice) {
if (!returnFavoredPlugins) { if (!returnFavoredPlugins) {
return []; return [];
} }
// for archived plugins, all stored plugins are enabled // for *imported* devices, all stored plugins are enabled
return allPlugins.filter( return allPlugins.filter(
(plugin) => client.plugins.indexOf(plugin.id) !== -1, (plugin) => client.plugins.indexOf(plugin.id) !== -1,
); );

View File

@@ -26,7 +26,11 @@ export function getFileName(extension: 'png' | 'mp4'): string {
return `screencap-${new Date().toISOString().replace(/:/g, '')}.${extension}`; return `screencap-${new Date().toISOString().replace(/:/g, '')}.${extension}`;
} }
export function capture(device: BaseDevice): Promise<string> { export async function capture(device: BaseDevice): Promise<string> {
if (device.isArchived) {
console.log('Skipping screenshot for archived device');
return '';
}
const pngPath = path.join(CAPTURE_LOCATION, getFileName('png')); const pngPath = path.join(CAPTURE_LOCATION, getFileName('png'));
return reportPlatformFailures( return reportPlatformFailures(
device.screenshot().then((buffer) => writeBufferToFile(pngPath, buffer)), device.screenshot().then((buffer) => writeBufferToFile(pngPath, buffer)),

View File

@@ -276,7 +276,13 @@ export class SandyPluginInstance extends BasePluginInstance {
private assertConnected() { private assertConnected() {
this.assertNotDestroyed(); this.assertNotDestroyed();
if (!this.connected.get()) { if (
// This is a better-safe-than-sorry; just the first condition should suffice
!this.connected.get() ||
!this.realClient.connected.get() ||
!this.device.isConnected ||
this.device.isArchived
) {
throw new Error('Plugin is not connected'); throw new Error('Plugin is not connected');
} }
} }

View File

@@ -208,7 +208,7 @@ export function startPlugin<Module extends FlipperPluginModule<any>>(
isBackgroundPlugin(_pluginId: string) { isBackgroundPlugin(_pluginId: string) {
return !!options?.isBackgroundPlugin; return !!options?.isBackgroundPlugin;
}, },
connected: createState(false), connected: createState(true),
initPlugin() { initPlugin() {
this.connected.set(true); this.connected.set(true);
pluginInstance.connect(); pluginInstance.connect();

View File

@@ -345,7 +345,7 @@ export function devicePlugin(client: DevicePluginClient) {
return { return {
logs: entries logs: entries
.get() .get()
.slice(-10000) .slice(-100 * 1000)
.map((e) => e.entry), .map((e) => e.entry),
}; };
}); });