"lint" command for flipper-pkg tool
Summary: Implemented json schema for flipper plugin package.json and used it for validation in "flipper-pkg lint" command. Nice thing about json schema is that it not only allows to validate json, but also can be referenced using "$schema" property in json so IDEs like VSCode can find it and use for code completion, validation and to show properties documentation. I'm going to deploy the schema as a part of documentation website so it can be referenced as https://fbflipper.com/schemas/plugin-package/v2.json. Also the "$schema" field can be used instead of "specVersion" to determine the specification according to which the plugin is defined. E.g., if specification version 3 would be created, it will be described in schema https://fbflipper.com/schemas/plugin-package/v3.json, etc. Reviewed By: passy Differential Revision: D21228294 fbshipit-source-id: f21351e584ef936a7d6b314436448489691f83a6
This commit is contained in:
committed by
Facebook GitHub Bot
parent
01f8d80402
commit
21c574ac80
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
test('tests are working', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
151
desktop/pkg/src/__tests__/runLint.node.ts
Normal file
151
desktop/pkg/src/__tests__/runLint.node.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 runLint from '../utils/runLint';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const validPackageJson = {
|
||||
$schema: 'https://fbflipper.com/schemas/plugin-package/v2.json',
|
||||
name: 'flipper-plugin-network',
|
||||
id: 'Network',
|
||||
flipperBundlerEntry: 'index.tsx',
|
||||
main: 'dist/index.js',
|
||||
title: 'Network',
|
||||
description:
|
||||
'Use the Network inspector to inspect outgoing network traffic in your apps.',
|
||||
icon: 'internet',
|
||||
version: '1.0.0',
|
||||
license: 'MIT',
|
||||
keywords: ['network', 'flipper-plugin'],
|
||||
bugs: {
|
||||
email: 'oncall+flipper@xmail.facebook.com',
|
||||
url: 'https://fb.workplace.com/groups/flippersupport/',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mock('fs-extra', () => jest.fn());
|
||||
fs.pathExists = jest.fn().mockResolvedValue(true);
|
||||
fs.pathExistsSync = jest.fn().mockReturnValue(true);
|
||||
fs.lstatSync = jest.fn().mockReturnValue({
|
||||
isFile: function () {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('valid package json', async () => {
|
||||
const json = JSON.stringify(validPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
test('$schema field is required', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
delete testPackageJson.$schema;
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
". should have required property \\"$schema\\" pointing to a supported schema URI, e.g.:
|
||||
{
|
||||
\\"$schema\\": \\"https://fbflipper.com/schemas/plugin-package/v2.json\\",
|
||||
\\"name\\": \\"flipper-plugin-example\\",
|
||||
...
|
||||
}",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('supported schema is required', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
testPackageJson.$schema =
|
||||
'https://fbflipper.com/schemas/plugin-package/v1.json';
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
".$schema should point to a supported schema. Currently supported schemas:
|
||||
- https://fbflipper.com/schemas/plugin-package/v2.json",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('name is required', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
delete testPackageJson.name;
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
". should have required property 'name'",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('name must start with "flipper-plugin-"', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
testPackageJson.name = 'test-plugin';
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"/name should start with \\"flipper-plugin-\\", e.g. \\"flipper-plugin-example\\"",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('keywords must contain "flipper-plugin"', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
testPackageJson.keywords = ['flipper', 'network'];
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"/keywords should contain keyword \\"flipper-plugin\\"",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('flippeBundlerEntry must point to an existing file', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
testPackageJson.flipperBundlerEntry = 'unexisting/file';
|
||||
fs.pathExistsSync = jest
|
||||
.fn()
|
||||
.mockImplementation((path) => !path.includes('unexisting/file'));
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"/flipperBundlerEntry should point to a valid file",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('multiple validation errors reported', async () => {
|
||||
const testPackageJson = Object.assign({}, validPackageJson);
|
||||
testPackageJson.keywords = ['flipper'];
|
||||
delete testPackageJson.flipperBundlerEntry;
|
||||
const json = JSON.stringify(testPackageJson);
|
||||
fs.readFile = jest.fn().mockResolvedValue(new Buffer(json));
|
||||
const result = await runLint('dir');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
". should have required property 'flipperBundlerEntry'",
|
||||
"/keywords should contain keyword \\"flipper-plugin\\"",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {runBuild, getPluginDetails} from 'flipper-pkg-lib';
|
||||
export default class Bundle extends Command {
|
||||
public static description = 'transpiles and bundles plugin';
|
||||
|
||||
public static examples = [`$ flipper-pkg bundle optional/path/to/directory`];
|
||||
public static examples = [`$ flipper-pkg bundle path/to/plugin`];
|
||||
|
||||
public static args: args.IArg[] = [
|
||||
{
|
||||
|
||||
48
desktop/pkg/src/commands/lint.ts
Normal file
48
desktop/pkg/src/commands/lint.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 {Command} from '@oclif/command';
|
||||
import {args} from '@oclif/parser';
|
||||
import path from 'path';
|
||||
import runLint from '../utils/runLint';
|
||||
|
||||
export default class Lint extends Command {
|
||||
public static description = 'validates a plugin package directory';
|
||||
|
||||
public static examples = [`$ flipper-pkg lint path/to/plugin`];
|
||||
|
||||
public static args: args.IArg[] = [
|
||||
{
|
||||
name: 'directory',
|
||||
required: false,
|
||||
default: '.',
|
||||
description:
|
||||
'Path to plugin package directory for linting. Defaults to the current working directory.',
|
||||
},
|
||||
];
|
||||
|
||||
public async run() {
|
||||
const {args} = this.parse(Lint);
|
||||
const inputDirectory: string = path.resolve(process.cwd(), args.directory);
|
||||
try {
|
||||
console.log(`⚙️ Validating ${inputDirectory}`);
|
||||
const errors = await runLint(inputDirectory);
|
||||
if (errors) {
|
||||
this.error(
|
||||
`Plugin package definition is invalid. See https://fbflipper.com/docs/extending/js-setup.html#plugin-definition for details.\n${errors.join(
|
||||
'\n',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.error(error);
|
||||
}
|
||||
console.log('✅ Plugin package definition is valid');
|
||||
}
|
||||
}
|
||||
96
desktop/pkg/src/utils/runLint.ts
Normal file
96
desktop/pkg/src/utils/runLint.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import Ajv from 'ajv';
|
||||
import filePathExists from './validation/filePathExists';
|
||||
|
||||
const pluginPackageJsonSchemaUrl =
|
||||
'https://fbflipper.com/schemas/plugin-package/v2.json';
|
||||
|
||||
const packageJsonSchemaUrl =
|
||||
'https://schemastore.azurewebsites.net/schemas/json/package.json';
|
||||
|
||||
const schemasDir = path.resolve(__dirname, '..', '..', 'schemas');
|
||||
const packageJsonSchemaPath = path.join(schemasDir, 'package.json');
|
||||
const pluginPackageJsonSchemaPath = path.join(
|
||||
schemasDir,
|
||||
'plugin-package-v2.json',
|
||||
);
|
||||
|
||||
export default async function runLint(
|
||||
inputDirectory: string,
|
||||
): Promise<null | string[]> {
|
||||
const packageJsonPath = path.join(inputDirectory, 'package.json');
|
||||
if (!(await fs.pathExists(packageJsonPath))) {
|
||||
return [
|
||||
`package.json not found in plugin source directory ${inputDirectory}.`,
|
||||
];
|
||||
}
|
||||
const packageJsonString = (await fs.readFile(packageJsonPath)).toString();
|
||||
const packageJson = JSON.parse(packageJsonString);
|
||||
if (!packageJson.$schema) {
|
||||
return [
|
||||
[
|
||||
`. should have required property "$schema" pointing to a supported schema URI, e.g.:`,
|
||||
`{`,
|
||||
` "$schema": "${pluginPackageJsonSchemaUrl}",`,
|
||||
` "name": "flipper-plugin-example",`,
|
||||
` ...`,
|
||||
`}`,
|
||||
].join('\n'),
|
||||
];
|
||||
}
|
||||
|
||||
if (packageJson.$schema != pluginPackageJsonSchemaUrl) {
|
||||
return [
|
||||
[
|
||||
`.$schema should point to a supported schema. Currently supported schemas:`,
|
||||
`- ${pluginPackageJsonSchemaUrl}`,
|
||||
].join('\n'),
|
||||
];
|
||||
}
|
||||
|
||||
const packageJsonSchema = await fs.readJson(packageJsonSchemaPath);
|
||||
const pluginPackageJsonSchema = await fs.readJson(
|
||||
pluginPackageJsonSchemaPath,
|
||||
);
|
||||
const ajv = new Ajv({
|
||||
allErrors: true,
|
||||
loadSchema,
|
||||
schemaId: 'auto',
|
||||
meta: true,
|
||||
jsonPointers: true,
|
||||
});
|
||||
require('ajv-errors')(ajv);
|
||||
ajv
|
||||
.addMetaSchema(require('ajv/lib/refs/json-schema-draft-04.json'))
|
||||
.addSchema(packageJsonSchema, packageJsonSchemaUrl)
|
||||
.addSchema(pluginPackageJsonSchema, pluginPackageJsonSchemaUrl)
|
||||
.addKeyword('filePathExists', filePathExists(inputDirectory));
|
||||
|
||||
const validate = await ajv.compileAsync(pluginPackageJsonSchema);
|
||||
const valid = await validate(packageJson);
|
||||
if (!valid) {
|
||||
return validate.errors
|
||||
? validate.errors.map(
|
||||
(error) =>
|
||||
`${error.dataPath === '' ? '.' : error.dataPath} ${
|
||||
error.message || 'unspecified error'
|
||||
}`,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSchema(_uri: string) {
|
||||
return false;
|
||||
}
|
||||
43
desktop/pkg/src/utils/validation/filePathExists.ts
Normal file
43
desktop/pkg/src/utils/validation/filePathExists.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 ajv from 'ajv';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export default function (inputDirectory: string) {
|
||||
const filePathExists: ajv.KeywordDefinition = {
|
||||
errors: true,
|
||||
compile: function validatePathExists(schema: any) {
|
||||
function filePathExistsValidator(value: any, dataPath: any) {
|
||||
const it = filePathExistsValidator as ajv.SchemaValidateFunction;
|
||||
if (!schema) {
|
||||
return true;
|
||||
}
|
||||
it.errors = [];
|
||||
const tpl = {
|
||||
keyword: 'filePathExists',
|
||||
dataPath,
|
||||
schemaPath: '',
|
||||
params: [],
|
||||
};
|
||||
const fullPath = path.resolve(inputDirectory, value);
|
||||
if (!fs.pathExistsSync(fullPath) || !fs.lstatSync(fullPath).isFile()) {
|
||||
it.errors.push({
|
||||
...tpl,
|
||||
message: `should point to a valid file`,
|
||||
});
|
||||
}
|
||||
return it.errors.length === 0;
|
||||
}
|
||||
return filePathExistsValidator;
|
||||
},
|
||||
};
|
||||
return filePathExists;
|
||||
}
|
||||
Reference in New Issue
Block a user