Yarn workspaces

Summary:
1) moved "sonar/desktop/src" to "sonar/desktop/app/src", so "app" is now a separate package containing the core Flipper app code
2) Configured yarn workspaces with the root in "sonar/desktop": app, static, pkg, doctor, headless-tests. Plugins are not included for now, I plan to do this later.

Reviewed By: jknoxville

Differential Revision: D20535782

fbshipit-source-id: 600b2301960f37c7d72166e0d04eba462bec9fc1
This commit is contained in:
Anton Nikolaev
2020-03-20 13:31:37 -07:00
committed by Facebook GitHub Bot
parent 676d7bbd24
commit 863f89351e
340 changed files with 1635 additions and 294 deletions

View File

@@ -0,0 +1,195 @@
/**
* 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 BaseDevice, {DeviceType, LogLevel} from './BaseDevice';
import adb, {Client as ADBClient} from 'adbkit';
import {Priority} from 'adbkit-logcat';
import ArchivedDevice from './ArchivedDevice';
import {createWriteStream} from 'fs';
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
export default class AndroidDevice extends BaseDevice {
constructor(
serial: string,
deviceType: DeviceType,
title: string,
adb: ADBClient,
) {
super(serial, deviceType, title, 'Android');
this.adb = adb;
this.icon = 'icons/android.svg';
this.adb.openLogcat(this.serial).then(reader => {
reader.on('entry', entry => {
let type: LogLevel = 'unknown';
if (entry.priority === Priority.VERBOSE) {
type = 'verbose';
}
if (entry.priority === Priority.DEBUG) {
type = 'debug';
}
if (entry.priority === Priority.INFO) {
type = 'info';
}
if (entry.priority === Priority.WARN) {
type = 'warn';
}
if (entry.priority === Priority.ERROR) {
type = 'error';
}
if (entry.priority === Priority.FATAL) {
type = 'fatal';
}
this.addLogEntry({
tag: entry.tag,
pid: entry.pid,
tid: entry.tid,
message: entry.message,
date: entry.date,
type,
});
});
});
}
adb: ADBClient;
pidAppMapping: {[key: number]: string} = {};
private recordingProcess?: Promise<string>;
supportedColumns(): Array<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
}
reverse(ports: [number, number]): Promise<void> {
return Promise.all(
ports.map(port =>
this.adb.reverse(this.serial, `tcp:${port}`, `tcp:${port}`),
),
).then(() => {
return;
});
}
clearLogs(): Promise<void> {
this.logEntries = [];
return this.executeShell(['logcat', '-c']);
}
archive(): ArchivedDevice {
return new ArchivedDevice({
serial: this.serial,
deviceType: this.deviceType,
title: this.title,
os: this.os,
logEntries: [...this.logEntries],
screenshotHandle: null,
});
}
navigateToLocation(location: string) {
const shellCommand = `am start ${encodeURI(location)}`;
this.adb.shell(this.serial, shellCommand);
}
screenshot(): Promise<Buffer> {
return new Promise((resolve, reject) => {
this.adb.screencap(this.serial).then(stream => {
const chunks: Array<Buffer> = [];
stream
.on('data', (chunk: Buffer) => chunks.push(chunk))
.once('end', () => {
resolve(Buffer.concat(chunks));
})
.once('error', reject);
});
});
}
async screenCaptureAvailable(): Promise<boolean> {
try {
await this.executeShell(
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
);
return true;
} catch (_e) {
return false;
}
}
private async executeShell(command: string | string[]): Promise<void> {
const output = await this.adb
.shell(this.serial, command)
.then(adb.util.readAll)
.then((output: Buffer) => output.toString().trim());
if (output) {
throw new Error(output);
}
}
private async isValidFile(filePath: string): Promise<boolean> {
const fileSize = await this.adb
.shell(this.serial, `du "${filePath}"`)
.then(adb.util.readAll)
.then((output: Buffer) =>
output
.toString()
.trim()
.split('\t'),
)
.then(x => Number(x[0]));
// 4 is what an empty file (touch file) already takes up, so it's
// definitely not a valid video file.
return fileSize > 4;
}
async startScreenCapture(destination: string) {
await this.executeShell(
`mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`,
);
const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`;
this.recordingProcess = this.adb
.shell(this.serial, `screenrecord --bugreport "${recordingLocation}"`)
.then(adb.util.readAll)
.then(async output => {
const isValid = await this.isValidFile(recordingLocation);
if (!isValid) {
const outputMessage = output.toString().trim();
throw new Error(
'Recording was not properly started: \n' + outputMessage,
);
}
})
.then(
_ =>
new Promise((resolve, reject) => {
this.adb.pull(this.serial, recordingLocation).then(stream => {
stream.on('end', resolve);
stream.on('error', reject);
stream.pipe(createWriteStream(destination));
});
}),
)
.then(_ => destination);
return this.recordingProcess.then(_ => {});
}
async stopScreenCapture(): Promise<string> {
const {recordingProcess} = this;
if (!recordingProcess) {
return Promise.reject(new Error('Recording was not properly started'));
}
await this.adb.shell(this.serial, `pgrep 'screenrecord' -L 2`);
const destination = await recordingProcess;
this.recordingProcess = undefined;
return destination;
}
}

View File

@@ -0,0 +1,73 @@
/**
* 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 BaseDevice from './BaseDevice';
import {DeviceType, OS, DeviceShell, DeviceLogEntry} from './BaseDevice';
import {SupportFormRequestDetailsState} from '../reducers/supportForm';
function normalizeArchivedDeviceType(deviceType: DeviceType): DeviceType {
let archivedDeviceType = deviceType;
if (archivedDeviceType === 'emulator') {
archivedDeviceType = 'archivedEmulator';
} else if (archivedDeviceType === 'physical') {
archivedDeviceType = 'archivedPhysical';
}
return archivedDeviceType;
}
export default class ArchivedDevice extends BaseDevice {
constructor(options: {
serial: string;
deviceType: DeviceType;
title: string;
os: OS;
logEntries: Array<DeviceLogEntry>;
screenshotHandle: string | null;
source?: string;
supportRequestDetails?: SupportFormRequestDetailsState;
}) {
super(
options.serial,
normalizeArchivedDeviceType(options.deviceType),
options.title,
options.os,
);
this.logs = options.logEntries;
this.source = options.source || '';
this.supportRequestDetails = options.supportRequestDetails;
this.archivedScreenshotHandle = options.screenshotHandle;
}
logs: Array<DeviceLogEntry>;
archivedScreenshotHandle: string | null;
isArchived = true;
displayTitle(): string {
return `${this.title} ${this.source ? '(Imported)' : '(Offline)'}`;
}
supportRequestDetails?: SupportFormRequestDetailsState;
getLogs() {
return this.logs;
}
clearLogs(): Promise<void> {
this.logs = [];
return Promise.resolve();
}
spawnShell(): DeviceShell | undefined | null {
return null;
}
getArchivedScreenshotHandle(): string | null {
return this.archivedScreenshotHandle;
}
}

View File

@@ -0,0 +1,183 @@
/**
* 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 stream from 'stream';
import {FlipperDevicePlugin} from 'flipper';
import {sortPluginsByName} from '../utils/pluginUtils';
export type LogLevel =
| 'unknown'
| 'verbose'
| 'debug'
| 'info'
| 'warn'
| 'error'
| 'fatal';
export type DeviceLogEntry = {
readonly date: Date;
readonly pid: number;
readonly tid: number;
readonly app?: string;
readonly type: LogLevel;
readonly tag: string;
readonly message: string;
};
export type DeviceShell = {
stdout: stream.Readable;
stderr: stream.Readable;
stdin: stream.Writable;
};
export type DeviceLogListener = (entry: DeviceLogEntry) => void;
export type DeviceType =
| 'emulator'
| 'physical'
| 'archivedEmulator'
| 'archivedPhysical';
export type DeviceExport = {
os: OS;
title: string;
deviceType: DeviceType;
serial: string;
logs: Array<DeviceLogEntry>;
};
export type OS = 'iOS' | 'Android' | 'Windows' | 'MacOS' | 'JSWebApp' | 'Metro';
export default class BaseDevice {
constructor(serial: string, deviceType: DeviceType, title: string, os: OS) {
this.serial = serial;
this.title = title;
this.deviceType = deviceType;
this.os = os;
}
// operating system of this device
os: OS;
// human readable name for this device
title: string;
// type of this device
deviceType: DeviceType;
// serial number for this device
serial: string;
// possible src of icon to display next to the device title
icon: string | null | undefined;
logListeners: Map<Symbol, DeviceLogListener> = new Map();
logEntries: Array<DeviceLogEntry> = [];
isArchived: boolean = false;
// if imported, stores the original source location
source = '';
// sorted list of supported device plugins
devicePlugins!: string[];
supportsOS(os: OS) {
return os.toLowerCase() === this.os.toLowerCase();
}
displayTitle(): string {
return this.title;
}
toJSON(): DeviceExport {
return {
os: this.os,
title: this.title,
deviceType: this.deviceType,
serial: this.serial,
logs: this.getLogs(),
};
}
teardown() {}
supportedColumns(): Array<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
}
addLogListener(callback: DeviceLogListener): Symbol {
const id = Symbol();
this.logListeners.set(id, callback);
return id;
}
_notifyLogListeners(entry: DeviceLogEntry) {
if (this.logListeners.size > 0) {
this.logListeners.forEach(listener => {
// prevent breaking other listeners, if one listener doesn't work.
try {
listener(entry);
} catch (e) {
console.error(`Log listener exception:`, e);
}
});
}
}
addLogEntry(entry: DeviceLogEntry) {
this.logEntries.push(entry);
this._notifyLogListeners(entry);
}
getLogs() {
return this.logEntries;
}
clearLogs(): Promise<void> {
// Only for device types that allow clearing.
this.logEntries = [];
return Promise.resolve();
}
removeLogListener(id: Symbol) {
this.logListeners.delete(id);
}
navigateToLocation(_location: string) {
throw new Error('unimplemented');
}
archive(): any | null | undefined {
return null;
}
screenshot(): Promise<Buffer> {
return Promise.reject(
new Error('No screenshot support for current device'),
);
}
async screenCaptureAvailable(): Promise<boolean> {
return false;
}
async startScreenCapture(_destination: string): Promise<void> {
throw new Error('startScreenCapture not implemented on BaseDevice ');
}
async stopScreenCapture(): Promise<string | null> {
return null;
}
loadDevicePlugins(devicePlugins?: Map<string, typeof FlipperDevicePlugin>) {
this.devicePlugins = Array.from(devicePlugins ? devicePlugins.values() : [])
.filter(plugin => plugin.supportsDevice(this))
.sort(sortPluginsByName)
.map(plugin => plugin.id);
}
}

View File

@@ -0,0 +1,249 @@
/**
* 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 {DeviceType, LogLevel, DeviceLogEntry} from './BaseDevice';
import child_process, {ChildProcess} from 'child_process';
import BaseDevice from './BaseDevice';
import JSONStream from 'JSONStream';
import {Transform} from 'stream';
import electron from 'electron';
import fs from 'fs';
import {v1 as uuid} from 'uuid';
import path from 'path';
import {promisify} from 'util';
import {exec} from 'child_process';
import {default as promiseTimeout} from '../utils/promiseTimeout';
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
type RawLogEntry = {
eventMessage: string;
machTimestamp: number;
messageType: IOSLogLevel;
processID: number;
processImagePath: string;
processImageUUID: string;
processUniqueID: number;
senderImagePath: string;
senderImageUUID: string;
senderProgramCounter: number;
threadID: number;
timestamp: string;
timezoneName: string;
traceID: string;
};
export default class IOSDevice extends BaseDevice {
log: any;
buffer: string;
private recordingProcess?: ChildProcess;
private recordingLocation?: string;
constructor(serial: string, deviceType: DeviceType, title: string) {
super(serial, deviceType, title, 'iOS');
this.icon = 'icons/ios.svg';
this.buffer = '';
this.log = this.startLogListener();
}
screenshot(): Promise<Buffer> {
const tmpImageName = uuid() + '.png';
const tmpDirectory = (electron.app || electron.remote.app).getPath('temp');
const tmpFilePath = path.join(tmpDirectory, tmpImageName);
const command = `xcrun simctl io booted screenshot ${tmpFilePath}`;
return promisify(exec)(command)
.then(() => promisify(fs.readFile)(tmpFilePath))
.then(buffer => {
return promisify(fs.unlink)(tmpFilePath).then(() => buffer);
});
}
navigateToLocation(location: string) {
const command = `xcrun simctl openurl booted "${location}"`;
exec(command);
}
teardown() {
if (this.log) {
this.log.kill();
}
}
supportedColumns(): Array<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
}
startLogListener(retries: number = 3) {
if (this.deviceType === 'physical') {
return;
}
if (retries === 0) {
console.error('Attaching iOS log listener continuously failed.');
return;
}
if (!this.log) {
const deviceSetPath = process.env.DEVICE_SET_PATH
? ['--set', process.env.DEVICE_SET_PATH]
: [];
this.log = child_process.spawn(
'xcrun',
[
'simctl',
...deviceSetPath,
'spawn',
'booted',
'log',
'stream',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--info',
'--debug',
],
{},
);
this.log.on('error', (err: Error) => {
console.error(err);
});
this.log.stderr.on('data', (data: Buffer) => {
console.error(data.toString());
});
this.log.on('exit', () => {
this.log = null;
});
}
try {
this.log.stdout
.pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => {
const entry = IOSDevice.parseLogEntry(data);
this.addLogEntry(entry);
});
} catch (e) {
console.error('Could not parse iOS log stream.', e);
// restart log stream
this.log.kill();
this.log = null;
this.startLogListener(retries - 1);
}
}
static parseLogEntry(entry: RawLogEntry): DeviceLogEntry {
const LOG_MAPPING: Map<IOSLogLevel, LogLevel> = new Map([
['Default' as IOSLogLevel, 'debug' as LogLevel],
['Info' as IOSLogLevel, 'info' as LogLevel],
['Debug' as IOSLogLevel, 'debug' as LogLevel],
['Error' as IOSLogLevel, 'error' as LogLevel],
['Fault' as IOSLogLevel, 'fatal' as LogLevel],
]);
let type: LogLevel = LOG_MAPPING.get(entry.messageType) || 'unknown';
// when Apple log levels are not used, log messages can be prefixed with
// their loglevel.
if (entry.eventMessage.startsWith('[debug]')) {
type = 'debug';
} else if (entry.eventMessage.startsWith('[info]')) {
type = 'info';
} else if (entry.eventMessage.startsWith('[warn]')) {
type = 'warn';
} else if (entry.eventMessage.startsWith('[error]')) {
type = 'error';
}
// remove type from mesage
entry.eventMessage = entry.eventMessage.replace(
/^\[(debug|info|warn|error)\]/,
'',
);
const tag = entry.processImagePath.split('/').pop() || '';
return {
date: new Date(entry.timestamp),
pid: entry.processID,
tid: entry.threadID,
tag,
message: entry.eventMessage,
type,
};
}
async screenCaptureAvailable() {
return this.deviceType === 'emulator';
}
async startScreenCapture(destination: string) {
this.recordingProcess = exec(
`xcrun simctl io booted recordVideo --codec=h264 --force ${destination}`,
);
this.recordingLocation = destination;
}
async stopScreenCapture(): Promise<string | null> {
if (this.recordingProcess && this.recordingLocation) {
const prom = new Promise<void>((resolve, _reject) => {
this.recordingProcess!.on(
'exit',
async (_code: number | null, _signal: NodeJS.Signals | null) => {
resolve();
},
);
this.recordingProcess!.kill('SIGINT');
});
const output: string | null = await promiseTimeout<void>(
5000,
prom,
'Timed out to stop a screen capture.',
)
.then(() => {
const {recordingLocation} = this;
this.recordingLocation = undefined;
return recordingLocation!;
})
.catch(_e => {
this.recordingLocation = undefined;
console.error(_e);
return null;
});
return output;
}
return null;
}
}
// Used to strip the initial output of the logging utility where it prints out settings.
// We know the log stream is json so it starts with an open brace.
class StripLogPrefix extends Transform {
passedPrefix = false;
_transform(
data: any,
encoding: string,
callback: (err?: Error, data?: any) => void,
) {
if (this.passedPrefix) {
this.push(data);
} else {
const dataString = data.toString();
const index = dataString.indexOf('[');
if (index >= 0) {
this.push(dataString.substring(index));
this.passedPrefix = true;
}
}
callback();
}
}

View File

@@ -0,0 +1,20 @@
/**
* 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 BaseDevice from './BaseDevice';
export default class JSDevice extends BaseDevice {
webContentsId: number;
constructor(serial: string, title: string, webContentsId: number) {
super(serial, 'emulator', title, 'JSWebApp');
this.devicePlugins = [];
this.webContentsId = webContentsId;
}
}

View File

@@ -0,0 +1,18 @@
/**
* 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 AndroidDevice from './AndroidDevice';
export default class KaiOSDevice extends AndroidDevice {
async screenCaptureAvailable() {
// The default way of capturing screenshots through adb does not seem to work
// There is a way of getting a screenshot through KaiOS dev tools though
return false;
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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 BaseDevice from './BaseDevice';
export default class MacDevice extends BaseDevice {
constructor() {
super('', 'physical', 'Mac', 'MacOS');
}
teardown() {}
supportedColumns(): Array<string> {
return [];
}
}

View File

@@ -0,0 +1,211 @@
/**
* 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 BaseDevice, {LogLevel} from './BaseDevice';
import ArchivedDevice from './ArchivedDevice';
import {EventEmitter} from 'events';
// From xplat/js/metro/packages/metro/src/lib/reporting.js
export type BundleDetails = {
entryFile: string;
platform?: string;
dev: boolean;
minify: boolean;
bundleType: string;
};
// From xplat/js/metro/packages/metro/src/lib/reporting.js
export type GlobalCacheDisabledReason = 'too_many_errors' | 'too_many_misses';
/**
* A tagged union of all the actions that may happen and we may want to
* report to the tool user.
*
* Based on xplat/js/metro/packages/metro/src/lib/TerminalReporter.js
*/
export type MetroReportableEvent =
| {
port: number;
projectRoots: ReadonlyArray<string>;
type: 'initialize_started';
}
| {type: 'initialize_done'}
| {
type: 'initialize_failed';
port: number;
error: Error;
}
| {
buildID: string;
type: 'bundle_build_done';
}
| {
buildID: string;
type: 'bundle_build_failed';
}
| {
buildID: string;
bundleDetails: BundleDetails;
type: 'bundle_build_started';
}
| {
error: Error;
type: 'bundling_error';
}
| {type: 'dep_graph_loading'}
| {type: 'dep_graph_loaded'}
| {
buildID: string;
type: 'bundle_transform_progressed';
transformedFileCount: number;
totalFileCount: number;
}
| {
type: 'global_cache_error';
error: Error;
}
| {
type: 'global_cache_disabled';
reason: GlobalCacheDisabledReason;
}
| {type: 'transform_cache_reset'}
| {
type: 'worker_stdout_chunk';
chunk: string;
}
| {
type: 'worker_stderr_chunk';
chunk: string;
}
| {
type: 'hmr_client_error';
error: Error;
}
| {
type: 'client_log';
level:
| 'trace'
| 'info'
| 'warn'
| 'log'
| 'group'
| 'groupCollapsed'
| 'groupEnd'
| 'debug';
data: Array<any>;
};
const metroLogLevelMapping: {[key: string]: LogLevel} = {
trace: 'verbose',
info: 'info',
warn: 'warn',
error: 'error',
log: 'info',
group: 'info',
groupCollapsed: 'info',
groupEnd: 'info',
debug: 'debug',
};
function getLoglevelFromMessageType(
type: MetroReportableEvent['type'],
): LogLevel | null {
switch (type) {
case 'bundle_build_done':
case 'bundle_build_started':
case 'initialize_done':
return 'debug';
case 'bundle_build_failed':
case 'bundling_error':
case 'global_cache_error':
case 'hmr_client_error':
return 'error';
case 'bundle_transform_progressed':
return null; // Don't show at all
case 'client_log':
return null; // Handled separately
case 'dep_graph_loaded':
case 'dep_graph_loading':
case 'global_cache_disabled':
default:
return 'verbose';
}
}
export default class MetroDevice extends BaseDevice {
ws?: WebSocket;
metroEventEmitter = new EventEmitter();
constructor(serial: string, ws: WebSocket | undefined) {
super(serial, 'emulator', 'React Native', 'Metro');
this.devicePlugins = [];
if (ws) {
this.ws = ws;
ws.onmessage = this._handleWSMessage;
}
}
private _handleWSMessage = ({data}: any) => {
const message: MetroReportableEvent = JSON.parse(data);
if (message.type === 'client_log') {
const type: LogLevel = metroLogLevelMapping[message.level] || 'unknown';
this.addLogEntry({
date: new Date(),
pid: 0,
tid: 0,
type,
tag: message.type,
message: message.data
.map(v =>
v && typeof v === 'object' ? JSON.stringify(v, null, 2) : v,
)
.join(' '),
});
} else {
const level = getLoglevelFromMessageType(message.type);
if (level !== null) {
this.addLogEntry({
date: new Date(),
pid: 0,
tid: 0,
type: level,
tag: message.type,
message: JSON.stringify(message, null, 2),
});
}
}
this.metroEventEmitter.emit('event', message);
};
sendCommand(command: string, params?: any) {
if (this.ws) {
this.ws.send(
JSON.stringify({
version: 2,
type: 'command',
command,
params,
}),
);
} else {
console.warn('Cannot send command, no connection', command);
}
}
archive() {
return new ArchivedDevice({
serial: this.serial,
deviceType: this.deviceType,
title: this.title,
os: this.os,
logEntries: [...this.logEntries],
screenshotHandle: null,
});
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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 BaseDevice from './BaseDevice';
export default class WindowsDevice extends BaseDevice {
constructor() {
super('', 'physical', 'Windows', 'Windows');
}
teardown() {}
supportedColumns(): Array<string> {
return [];
}
}