Skip to content

Commit

Permalink
feat(cli): introduce the onlyPlugins option (#246)
Browse files Browse the repository at this point in the history
Close #119
  • Loading branch information
IKatsuba committed Nov 16, 2023
1 parent d609bb3 commit 13c9d26
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 21 deletions.
2 changes: 2 additions & 0 deletions e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap
Expand Up @@ -26,6 +26,8 @@ Options:
--upload.project Project slug from portal [string]
--upload.server URL to your portal server [string]
--upload.apiKey API key for the portal server [string]
--onlyPlugins List of plugins to run. If not set all plugins are
run. [array] [default: []]
-h, --help Show help [boolean]
"
`;
2 changes: 2 additions & 0 deletions e2e/cli-e2e/tests/print-config.spec.ts
Expand Up @@ -37,6 +37,7 @@ describe('print-config', () => {
]),
// @TODO add test data to config file
categories: expect.any(Array),
onlyPlugins: [],
});
});

Expand All @@ -61,6 +62,7 @@ describe('print-config', () => {
}),
plugins: expect.any(Array),
categories: expect.any(Array),
onlyPlugins: [],
});
});

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/autorun/command-object.ts
Expand Up @@ -7,6 +7,7 @@ import {
upload,
} from '@code-pushup/core';
import { CLI_NAME } from '../cli';
import { onlyPluginsOption } from '../implementation/only-config-option';

type AutorunOptions = CollectOptions & UploadOptions;

Expand All @@ -15,6 +16,9 @@ export function yargsAutorunCommandObject() {
return {
command,
describe: 'Shortcut for running collect followed by upload',
builder: {
onlyPlugins: onlyPluginsOption,
},
handler: async <T>(args: ArgumentsCamelCase<T>) => {
console.log(chalk.bold(CLI_NAME));
console.log(chalk.gray(`Run ${command}...`));
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/collect/command-object.ts
Expand Up @@ -5,12 +5,16 @@ import {
collectAndPersistReports,
} from '@code-pushup/core';
import { CLI_NAME } from '../cli';
import { onlyPluginsOption } from '../implementation/only-config-option';

export function yargsCollectCommandObject(): CommandModule {
const command = 'collect';
return {
command,
describe: 'Run Plugins and collect results',
builder: {
onlyPlugins: onlyPluginsOption,
},
handler: async <T>(args: ArgumentsCamelCase<T>) => {
const options = args as unknown as CollectAndPersistReportsOptions;
console.log(chalk.bold(CLI_NAME));
Expand Down
117 changes: 115 additions & 2 deletions packages/cli/src/lib/implementation/config-middleware.spec.ts
@@ -1,7 +1,13 @@
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { expect } from 'vitest';
import { configMiddleware } from './config-middleware';
import { SpyInstance, afterEach, beforeEach, describe, expect } from 'vitest';
import { CoreConfig } from '@code-pushup/models';
import {
configMiddleware,
filterCategoryByOnlyPluginsOption,
filterPluginsByOnlyPluginsOption,
validateOnlyPluginsOption,
} from './config-middleware';

const __dirname = dirname(fileURLToPath(import.meta.url));
const withDirName = (path: string) => join(__dirname, path);
Expand Down Expand Up @@ -52,3 +58,110 @@ describe('applyConfigMiddleware', () => {
expect(error?.message).toContain(defaultConfigPath);
});
});

describe('filterPluginsByOnlyPluginsOption', () => {
it('should return all plugins if no onlyPlugins option', async () => {
const plugins = [
{ slug: 'plugin1' },
{ slug: 'plugin2' },
{ slug: 'plugin3' },
];
const filtered = filterPluginsByOnlyPluginsOption(
plugins as CoreConfig['plugins'],
{},
);
expect(filtered).toEqual(plugins);
});

it('should return only plugins with matching slugs', () => {
const plugins = [
{ slug: 'plugin1' },
{ slug: 'plugin2' },
{ slug: 'plugin3' },
];
const filtered = filterPluginsByOnlyPluginsOption(
plugins as CoreConfig['plugins'],
{
onlyPlugins: ['plugin1', 'plugin3'],
},
);
expect(filtered).toEqual([{ slug: 'plugin1' }, { slug: 'plugin3' }]);
});
});

// without the `no-secrets` rule, this would be flagged as a security issue
// eslint-disable-next-line no-secrets/no-secrets
describe('filterCategoryByOnlyPluginsOption', () => {
let logSpy: SpyInstance;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log');
});

afterEach(() => {
logSpy.mockRestore();
});

it('should return all categories if no onlyPlugins option', () => {
const categories = [
{ refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
{ refs: [{ slug: 'plugin3' }] },
];
const filtered = filterCategoryByOnlyPluginsOption(
categories as CoreConfig['categories'],
{},
);
expect(filtered).toEqual(categories);
});

it('should return only categories with matching slugs', () => {
const categories = [
{ refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
{ refs: [{ slug: 'plugin3' }] },
];
const filtered = filterCategoryByOnlyPluginsOption(
categories as CoreConfig['categories'],
{
onlyPlugins: ['plugin1', 'plugin3'],
},
);
expect(filtered).toEqual([{ refs: [{ slug: 'plugin3' }] }]);
});

it('should log if category is ignored', () => {
const categories = [
{ title: 'category1', refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
{ title: 'category2', refs: [{ slug: 'plugin3' }] },
];
filterCategoryByOnlyPluginsOption(categories as CoreConfig['categories'], {
onlyPlugins: ['plugin1', 'plugin3'],
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"category1"'));
});
});

describe('validateOnlyPluginsOption', () => {
let logSpy: SpyInstance;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log');
});

afterEach(() => {
logSpy.mockRestore();
});

it('should log if onlyPlugins option contains non-existing plugin', () => {
const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }];
validateOnlyPluginsOption(plugins as CoreConfig['plugins'], {
onlyPlugins: ['plugin1', 'plugin3'],
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"plugin3"'));
});

it('should not log if onlyPlugins option contains existing plugin', () => {
const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }];
validateOnlyPluginsOption(plugins as CoreConfig['plugins'], {
onlyPlugins: ['plugin1'],
});
expect(logSpy).not.toHaveBeenCalled();
});
});
105 changes: 88 additions & 17 deletions packages/cli/src/lib/implementation/config-middleware.ts
@@ -1,29 +1,100 @@
import chalk from 'chalk';
import { readCodePushupConfig } from '@code-pushup/core';
import { CoreConfig } from '@code-pushup/models';
import { GeneralCliOptions } from './model';
import { OnlyPluginsOptions } from './only-plugins-options';

export async function configMiddleware<
T extends Partial<GeneralCliOptions & CoreConfig>,
T extends Partial<GeneralCliOptions & CoreConfig & OnlyPluginsOptions>,
>(processArgs: T) {
const args = processArgs as T;
const { config, ...cliOptions } = args as GeneralCliOptions &
Required<CoreConfig>;
Required<CoreConfig> &
OnlyPluginsOptions;
const importedRc = await readCodePushupConfig(config);
const parsedProcessArgs: CoreConfig & GeneralCliOptions = {
config,
progress: cliOptions.progress,
verbose: cliOptions.verbose,
upload: {
...importedRc?.upload,
...cliOptions?.upload,
},
persist: {
...importedRc.persist,
...cliOptions?.persist,
},
plugins: importedRc.plugins,
categories: importedRc.categories,
};

validateOnlyPluginsOption(importedRc.plugins, cliOptions);

const parsedProcessArgs: CoreConfig & GeneralCliOptions & OnlyPluginsOptions =
{
config,
progress: cliOptions.progress,
verbose: cliOptions.verbose,
upload: {
...importedRc?.upload,
...cliOptions?.upload,
},
persist: {
...importedRc.persist,
...cliOptions?.persist,
},
plugins: filterPluginsByOnlyPluginsOption(importedRc.plugins, cliOptions),
categories: filterCategoryByOnlyPluginsOption(
importedRc.categories,
cliOptions,
),
onlyPlugins: cliOptions.onlyPlugins,
};

return parsedProcessArgs;
}

export function filterPluginsByOnlyPluginsOption(
plugins: CoreConfig['plugins'],
{ onlyPlugins }: { onlyPlugins?: string[] },
): CoreConfig['plugins'] {
if (!onlyPlugins?.length) {
return plugins;
}
return plugins.filter(plugin => onlyPlugins.includes(plugin.slug));
}

// skip the whole category if it has at least one skipped plugin ref
// see https://github.com/code-pushup/cli/pull/246#discussion_r1392274281
export function filterCategoryByOnlyPluginsOption(
categories: CoreConfig['categories'],
{ onlyPlugins }: { onlyPlugins?: string[] },
): CoreConfig['categories'] {
if (!onlyPlugins?.length) {
return categories;
}

return categories.filter(category =>
category.refs.every(ref => {
const isNotSkipped = onlyPlugins.includes(ref.slug);

if (!isNotSkipped) {
console.log(
`${chalk.yellow('⚠')} Category "${
category.title
}" is ignored because it references audits from skipped plugin "${
ref.slug
}"`,
);
}

return isNotSkipped;
}),
);
}

export function validateOnlyPluginsOption(
plugins: CoreConfig['plugins'],
{ onlyPlugins }: { onlyPlugins?: string[] },
): void {
const missingPlugins = onlyPlugins?.length
? onlyPlugins.filter(plugin => !plugins.some(({ slug }) => slug === plugin))
: [];

if (missingPlugins.length) {
console.log(
`${chalk.yellow(
'⚠',
)} The --onlyPlugin argument references plugins with "${missingPlugins.join(
'", "',
)}" slugs, but no such plugin is present in the configuration. Expected one of the following plugin slugs: "${plugins
.map(({ slug }) => slug)
.join('", "')}".`,
);
}
}
17 changes: 17 additions & 0 deletions packages/cli/src/lib/implementation/filter-kebab-case-keys.spec.ts
@@ -0,0 +1,17 @@
import { expect } from 'vitest';
import { filterKebabCaseKeys } from './filter-kebab-case-keys';

describe('filterKebabCaseKeys', () => {
it('should filter kebab-case keys', () => {
const obj = {
'kebab-case': 'value',
camelCase: 'value',
snake_case: 'value',
};
const filtered = filterKebabCaseKeys(obj);
expect(filtered).toEqual({
camelCase: 'value',
snake_case: 'value',
});
});
});
14 changes: 14 additions & 0 deletions packages/cli/src/lib/implementation/filter-kebab-case-keys.ts
@@ -0,0 +1,14 @@
export function filterKebabCaseKeys<T extends Record<string, unknown>>(
obj: T,
): T {
const newObj: Record<string, unknown> = {};

Object.keys(obj).forEach(key => {
if (key.includes('-')) {
return;
}
newObj[key] = obj[key];
});

return newObj as T;
}
8 changes: 8 additions & 0 deletions packages/cli/src/lib/implementation/only-config-option.ts
@@ -0,0 +1,8 @@
import { Options } from 'yargs';

export const onlyPluginsOption: Options = {
describe: 'List of plugins to run. If not set all plugins are run.',
type: 'array',
default: [],
coerce: (arg: string[]) => arg.flatMap(v => v.split(',')),
};
3 changes: 3 additions & 0 deletions packages/cli/src/lib/implementation/only-plugins-options.ts
@@ -0,0 +1,3 @@
export interface OnlyPluginsOptions {
onlyPlugins: string[];
}
12 changes: 10 additions & 2 deletions packages/cli/src/lib/print-config/command-object.ts
@@ -1,13 +1,21 @@
import { CommandModule } from 'yargs';
import { filterKebabCaseKeys } from '../implementation/filter-kebab-case-keys';
import { onlyPluginsOption } from '../implementation/only-config-option';

export function yargsConfigCommandObject() {
const command = 'print-config';
return {
command,
describe: 'Print config',
handler: args => {
builder: {
onlyPlugins: onlyPluginsOption,
},
handler: yargsArgs => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _, $0, ...cleanArgs } = args;
const { _, $0, ...args } = yargsArgs;
// it is important to filter out kebab case keys
// because yargs duplicates options in camel case and kebab case
const cleanArgs = filterKebabCaseKeys(args);
console.log(JSON.stringify(cleanArgs, null, 2));
},
} satisfies CommandModule;
Expand Down

0 comments on commit 13c9d26

Please sign in to comment.