Device plugin management (4/n): Allow choosing "device" plugin type in "flipper-pkg init"

Summary:
Added option to bootstrap device plugin in "flipper-pkg".

Changelog: "flipper-pkg init" can now be used to bootstrap device plugins

Reviewed By: mweststrate

Differential Revision: D26389429

fbshipit-source-id: 90773011bd50289004cd747111e1787402840922
This commit is contained in:
Anton Nikolaev
2021-02-16 10:46:11 -08:00
committed by Facebook GitHub Bot
parent 4cb40de3f5
commit 68248a7c63
11 changed files with 468 additions and 9 deletions

View File

@@ -4,7 +4,16 @@
"outDir": "lib",
"rootDir": "src"
},
"references": [{"path": "../plugin-lib"}],
"include": ["src"],
"exclude": ["node_modules"]
"references": [
{
"path": "../plugin-lib"
}
],
"include": [
"src"
],
"exclude": [
"node_modules",
"**/__tests__/*"
]
}

View File

@@ -41,7 +41,7 @@
"errorMessage": "should contain keyword \"flipper-plugin\""
},
"pluginType": {
"description": "Type of the plugin - client or device. If omitted, \"client\" type is used by default.",
"description": "Type of the plugin - \"client\" if the plugin connects to a specific client plugin running in a mobile app, or \"device\" if it connects to devices. If omitted, \"client\" type is assumed by default.",
"type": "string",
"enum": ["client", "device"]
},

View File

@@ -37,8 +37,14 @@ afterEach(() => {
// fs.writeFile.mockRestore();
});
test('It generates the correct files', async () => {
await initTemplate('my weird Package %name. etc', 'Nice title', '/dev/null');
test('It generates the correct files for client plugin', async () => {
await initTemplate(
'my weird Package %name. etc',
'Nice title',
'client',
undefined,
'/dev/null',
);
expect(files).toMatchInlineSnapshot(`
Object {
"/dev/null/.gitignore": "node_modules
@@ -57,6 +63,7 @@ test('It generates the correct files', async () => {
\\"name\\": \\"flipper-plugin-my-weird-package-name-etc\\",
\\"id\\": \\"my weird Package %name. etc\\",
\\"version\\": \\"1.0.0\\",
\\"pluginType\\": \\"client\\",
\\"main\\": \\"dist/bundle.js\\",
\\"flipperBundlerEntry\\": \\"src/index.tsx\\",
\\"license\\": \\"MIT\\",
@@ -206,3 +213,202 @@ test('It generates the correct files', async () => {
}
`);
});
test('It generates the correct files for device plugin', async () => {
await initTemplate(
'my weird Package %name. etc',
'Nice title',
'device',
['iOS', 'Android'],
'/dev/null',
);
expect(files).toMatchInlineSnapshot(`
Object {
"/dev/null/.gitignore": "node_modules
dist/
",
"/dev/null/babel.config.js": "module.exports = {
presets: [
'@babel/preset-typescript',
'@babel/preset-react',
['@babel/preset-env', {targets: {node: 'current'}}]
],
};
",
"/dev/null/package.json": "{
\\"$schema\\": \\"https://fbflipper.com/schemas/plugin-package/v2.json\\",
\\"name\\": \\"flipper-plugin-my-weird-package-name-etc\\",
\\"id\\": \\"my weird Package %name. etc\\",
\\"version\\": \\"1.0.0\\",
\\"pluginType\\": \\"device\\",
\\"supportedDevices\\": [{\\"os\\":\\"iOS\\"},{\\"os\\":\\"Android\\"}],
\\"main\\": \\"dist/bundle.js\\",
\\"flipperBundlerEntry\\": \\"src/index.tsx\\",
\\"license\\": \\"MIT\\",
\\"keywords\\": [
\\"flipper-plugin\\"
],
\\"icon\\": \\"apps\\",
\\"title\\": \\"Nice title\\",
\\"scripts\\": {
\\"lint\\": \\"flipper-pkg lint\\",
\\"prepack\\": \\"flipper-pkg lint && flipper-pkg bundle\\",
\\"build\\": \\"flipper-pkg bundle\\",
\\"watch\\": \\"flipper-pkg bundle --watch\\",
\\"test\\": \\"jest --no-watchman\\"
},
\\"peerDependencies\\": {
\\"flipper\\": \\"latest\\",
\\"flipper-plugin\\": \\"latest\\",
\\"antd\\": \\"latest\\"
},
\\"devDependencies\\": {
\\"@babel/preset-react\\": \\"latest\\",
\\"@babel/preset-typescript\\": \\"latest\\",
\\"@testing-library/react\\": \\"latest\\",
\\"@types/jest\\": \\"latest\\",
\\"@types/react\\": \\"latest\\",
\\"@types/react-dom\\": \\"latest\\",
\\"antd\\": \\"latest\\",
\\"flipper\\": \\"latest\\",
\\"flipper-plugin\\": \\"latest\\",
\\"flipper-pkg\\": \\"latest\\",
\\"jest\\": \\"latest\\",
\\"typescript\\": \\"latest\\"
}
}
",
"/dev/null/src/__tests__/test.spec.tsx": "import {TestUtils} from 'flipper-plugin';
import * as Plugin from '..';
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can store data', () => {
const {instance, sendLogEntry} = TestUtils.startDevicePlugin(Plugin);
expect(instance.data.get()).toEqual([]);
sendLogEntry({
date: new Date(1611854112859),
message: 'test1',
pid: 0,
tag: 'test',
tid: 1,
type: 'error',
app: 'X',
});
sendLogEntry({
date: new Date(1611854117859),
message: 'test2',
pid: 2,
tag: 'test',
tid: 3,
type: 'warn',
app: 'Y',
});
expect(instance.data.get()).toMatchInlineSnapshot(\`
Array [
\\"test1\\",
\\"test2\\",
]
\`);
});
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can render data', async () => {
const {instance, renderer, sendLogEntry} = TestUtils.renderDevicePlugin(
Plugin,
);
expect(instance.data.get()).toEqual([]);
sendLogEntry({
date: new Date(1611854112859),
message: 'test1',
pid: 0,
tag: 'test',
tid: 1,
type: 'error',
app: 'X',
});
sendLogEntry({
date: new Date(1611854117859),
message: 'test2',
pid: 2,
tag: 'test',
tid: 3,
type: 'warn',
app: 'Y',
});
expect(await renderer.findByTestId('0')).not.toBeNull();
expect(await renderer.findByTestId('1')).toMatchInlineSnapshot();
});
",
"/dev/null/src/index.tsx": "import React from 'react';
import {
DevicePluginClient,
usePlugin,
createState,
useValue,
Layout,
} from 'flipper-plugin';
// Read more: https://fbflipper.com/docs/tutorial/js-custom#creating-a-first-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#pluginclient
export function devicePlugin(client: DevicePluginClient) {
const data = createState<string[]>([]);
client.device.onLogEntry((entry) => {
data.update((draft) => {
draft.push(entry.message);
});
});
client.addMenuEntry({
action: 'clear',
handler: async () => {
data.set([]);
},
});
return {data};
}
// Read more: https://fbflipper.com/docs/tutorial/js-custom#building-a-user-interface-for-the-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#react-hooks
export function Component() {
const instance = usePlugin(devicePlugin);
const data = useValue(instance.data);
return (
<Layout.ScrollContainer>
{Object.entries(data).map(([id, d]) => (
<pre key={id} data-testid={id}>
{JSON.stringify(d)}
</pre>
))}
</Layout.ScrollContainer>
);
}
",
"/dev/null/tsconfig.json": "{
\\"compilerOptions\\": {
\\"target\\": \\"ES2017\\",
\\"module\\": \\"ES6\\",
\\"jsx\\": \\"react\\",
\\"sourceMap\\": true,
\\"noEmit\\": true,
\\"strict\\": true,
\\"moduleResolution\\": \\"node\\",
\\"esModuleInterop\\": true,
\\"forceConsistentCasingInFileNames\\": true
},
\\"files\\": [\\"src/index.tsx\\"]
}
",
}
`);
});

View File

@@ -16,10 +16,24 @@ import recursiveReaddirImport from 'recursive-readdir';
import {promisify} from 'util';
import inquirer from 'inquirer';
import {homedir} from 'os';
import {PluginType} from 'flipper-plugin-lib';
const recursiveReaddir = promisify<string, string[]>(recursiveReaddirImport);
const templateDir = path.resolve(__dirname, '..', '..', 'templates', 'plugin');
const pluginTemplateDir = path.resolve(
__dirname,
'..',
'..',
'templates',
'plugin',
);
const devicePluginTemplateDir = path.resolve(
__dirname,
'..',
'..',
'templates',
'device-plugin',
);
const templateExt = '.template';
export default class Init extends Command {
@@ -43,6 +57,18 @@ export default class Init extends Command {
const pluginDirectory: string = path.resolve(process.cwd(), args.directory);
await verifyFlipperSearchPath(pluginDirectory);
const pluginTypeQuestion: inquirer.QuestionCollection = [
{
type: 'list',
name: 'pluginType',
choices: ['client', 'device'],
message:
'Plugin Type ("client" if the plugin will work with a mobile app, "device" if the plugin will work with a mobile device):',
default: 'client',
},
];
const pluginType: PluginType = (await inquirer.prompt(pluginTypeQuestion))
.pluginType;
const idQuestion: inquirer.QuestionCollection = [
{
type: 'input',
@@ -61,6 +87,24 @@ export default class Init extends Command {
},
];
const title: string = (await inquirer.prompt(titleQuestion)).title;
let supportedDevices: string[] | undefined;
if (pluginType === 'device') {
const supportedDevicesQuestion: inquirer.QuestionCollection = [
{
type: 'checkbox',
name: 'supportedDevices',
choices: ['iOS', 'Android', 'Metro'],
message:
'Supported Devices (iOS, Android or Metro (React Native bundler)):',
default: ['iOS', 'Android'],
},
];
supportedDevices = (await inquirer.prompt(supportedDevicesQuestion))
.supportedDevices;
}
const packageName = getPackageNameFromId(id);
const outputDirectory = path.join(pluginDirectory, packageName);
@@ -72,7 +116,13 @@ export default class Init extends Command {
`⚙️ Initializing Flipper desktop template in ${outputDirectory}`,
);
await fs.ensureDir(outputDirectory);
await initTemplate(id, title, outputDirectory);
await initTemplate(
id,
title,
pluginType,
supportedDevices,
outputDirectory,
);
console.log(`⚙️ Installing dependencies`);
spawnSync('yarn', ['install'], {cwd: outputDirectory, stdio: [0, 1, 2]});
@@ -93,9 +143,13 @@ function getPackageNameFromId(id: string): string {
export async function initTemplate(
id: string,
title: string,
pluginType: PluginType,
supportedDevices: string[] | undefined,
outputDirectory: string,
) {
const packageName = getPackageNameFromId(id);
const templateDir =
pluginType === 'device' ? devicePluginTemplateDir : pluginTemplateDir;
const templateItems = await recursiveReaddir(templateDir);
for (const item of templateItems) {
@@ -115,6 +169,16 @@ export async function initTemplate(
.toString()
.replace('{{id}}', id)
.replace('{{title}}', title)
.replace(
'{{supported_devices}}',
JSON.stringify(
supportedDevices
? supportedDevices.map((d) => ({
os: d,
}))
: [],
),
)
.replace('{{package_name}}', packageName);
await fs.writeFile(newFile, content);
}

View File

@@ -0,0 +1,2 @@
node_modules
dist/

View File

@@ -0,0 +1,7 @@
module.exports = {
presets: [
'@babel/preset-typescript',
'@babel/preset-react',
['@babel/preset-env', {targets: {node: 'current'}}]
],
};

View File

@@ -0,0 +1,42 @@
{
"$schema": "https://fbflipper.com/schemas/plugin-package/v2.json",
"name": "{{package_name}}",
"id": "{{id}}",
"version": "1.0.0",
"pluginType": "device",
"supportedDevices": {{supported_devices}},
"main": "dist/bundle.js",
"flipperBundlerEntry": "src/index.tsx",
"license": "MIT",
"keywords": [
"flipper-plugin"
],
"icon": "apps",
"title": "{{title}}",
"scripts": {
"lint": "flipper-pkg lint",
"prepack": "flipper-pkg lint && flipper-pkg bundle",
"build": "flipper-pkg bundle",
"watch": "flipper-pkg bundle --watch",
"test": "jest --no-watchman"
},
"peerDependencies": {
"flipper": "latest",
"flipper-plugin": "latest",
"antd": "latest"
},
"devDependencies": {
"@babel/preset-react": "latest",
"@babel/preset-typescript": "latest",
"@testing-library/react": "latest",
"@types/jest": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"antd": "latest",
"flipper": "latest",
"flipper-plugin": "latest",
"flipper-pkg": "latest",
"jest": "latest",
"typescript": "latest"
}
}

View File

@@ -0,0 +1,68 @@
import {TestUtils} from 'flipper-plugin';
import * as Plugin from '..';
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can store data', () => {
const {instance, sendLogEntry} = TestUtils.startDevicePlugin(Plugin);
expect(instance.data.get()).toEqual([]);
sendLogEntry({
date: new Date(1611854112859),
message: 'test1',
pid: 0,
tag: 'test',
tid: 1,
type: 'error',
app: 'X',
});
sendLogEntry({
date: new Date(1611854117859),
message: 'test2',
pid: 2,
tag: 'test',
tid: 3,
type: 'warn',
app: 'Y',
});
expect(instance.data.get()).toMatchInlineSnapshot(`
Array [
"test1",
"test2",
]
`);
});
// Read more: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
// API: https://fbflipper.com/docs/tutorial/js-custom#testing-plugin-logic
test('It can render data', async () => {
const {instance, renderer, sendLogEntry} = TestUtils.renderDevicePlugin(
Plugin,
);
expect(instance.data.get()).toEqual([]);
sendLogEntry({
date: new Date(1611854112859),
message: 'test1',
pid: 0,
tag: 'test',
tid: 1,
type: 'error',
app: 'X',
});
sendLogEntry({
date: new Date(1611854117859),
message: 'test2',
pid: 2,
tag: 'test',
tid: 3,
type: 'warn',
app: 'Y',
});
expect(await renderer.findByTestId('0')).not.toBeNull();
expect(await renderer.findByTestId('1')).toMatchInlineSnapshot();
});

View File

@@ -0,0 +1,46 @@
import React from 'react';
import {
DevicePluginClient,
usePlugin,
createState,
useValue,
Layout,
} from 'flipper-plugin';
// Read more: https://fbflipper.com/docs/tutorial/js-custom#creating-a-first-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#pluginclient
export function devicePlugin(client: DevicePluginClient) {
const data = createState<string[]>([]);
client.device.onLogEntry((entry) => {
data.update((draft) => {
draft.push(entry.message);
});
});
client.addMenuEntry({
action: 'clear',
handler: async () => {
data.set([]);
},
});
return {data};
}
// Read more: https://fbflipper.com/docs/tutorial/js-custom#building-a-user-interface-for-the-plugin
// API: https://fbflipper.com/docs/extending/flipper-plugin#react-hooks
export function Component() {
const instance = usePlugin(devicePlugin);
const data = useValue(instance.data);
return (
<Layout.ScrollContainer>
{Object.entries(data).map(([id, d]) => (
<pre key={id} data-testid={id}>
{JSON.stringify(d)}
</pre>
))}
</Layout.ScrollContainer>
);
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "ES6",
"jsx": "react",
"sourceMap": true,
"noEmit": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"files": ["src/index.tsx"]
}

View File

@@ -3,6 +3,7 @@
"name": "{{package_name}}",
"id": "{{id}}",
"version": "1.0.0",
"pluginType": "client",
"main": "dist/bundle.js",
"flipperBundlerEntry": "src/index.tsx",
"license": "MIT",