Skip to content

Commit 13c9d26

Browse files
authored
feat(cli): introduce the onlyPlugins option (#246)
Close #119
1 parent d609bb3 commit 13c9d26

11 files changed

+267
-21
lines changed

e2e/cli-e2e/tests/__snapshots__/help.spec.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Options:
2626
--upload.project Project slug from portal [string]
2727
--upload.server URL to your portal server [string]
2828
--upload.apiKey API key for the portal server [string]
29+
--onlyPlugins List of plugins to run. If not set all plugins are
30+
run. [array] [default: []]
2931
-h, --help Show help [boolean]
3032
"
3133
`;

e2e/cli-e2e/tests/print-config.spec.ts

+2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('print-config', () => {
3737
]),
3838
// @TODO add test data to config file
3939
categories: expect.any(Array),
40+
onlyPlugins: [],
4041
});
4142
});
4243

@@ -61,6 +62,7 @@ describe('print-config', () => {
6162
}),
6263
plugins: expect.any(Array),
6364
categories: expect.any(Array),
65+
onlyPlugins: [],
6466
});
6567
});
6668

packages/cli/src/lib/autorun/command-object.ts

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
upload,
88
} from '@code-pushup/core';
99
import { CLI_NAME } from '../cli';
10+
import { onlyPluginsOption } from '../implementation/only-config-option';
1011

1112
type AutorunOptions = CollectOptions & UploadOptions;
1213

@@ -15,6 +16,9 @@ export function yargsAutorunCommandObject() {
1516
return {
1617
command,
1718
describe: 'Shortcut for running collect followed by upload',
19+
builder: {
20+
onlyPlugins: onlyPluginsOption,
21+
},
1822
handler: async <T>(args: ArgumentsCamelCase<T>) => {
1923
console.log(chalk.bold(CLI_NAME));
2024
console.log(chalk.gray(`Run ${command}...`));

packages/cli/src/lib/collect/command-object.ts

+4
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import {
55
collectAndPersistReports,
66
} from '@code-pushup/core';
77
import { CLI_NAME } from '../cli';
8+
import { onlyPluginsOption } from '../implementation/only-config-option';
89

910
export function yargsCollectCommandObject(): CommandModule {
1011
const command = 'collect';
1112
return {
1213
command,
1314
describe: 'Run Plugins and collect results',
15+
builder: {
16+
onlyPlugins: onlyPluginsOption,
17+
},
1418
handler: async <T>(args: ArgumentsCamelCase<T>) => {
1519
const options = args as unknown as CollectAndPersistReportsOptions;
1620
console.log(chalk.bold(CLI_NAME));

packages/cli/src/lib/implementation/config-middleware.spec.ts

+115-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { dirname, join } from 'path';
22
import { fileURLToPath } from 'url';
3-
import { expect } from 'vitest';
4-
import { configMiddleware } from './config-middleware';
3+
import { SpyInstance, afterEach, beforeEach, describe, expect } from 'vitest';
4+
import { CoreConfig } from '@code-pushup/models';
5+
import {
6+
configMiddleware,
7+
filterCategoryByOnlyPluginsOption,
8+
filterPluginsByOnlyPluginsOption,
9+
validateOnlyPluginsOption,
10+
} from './config-middleware';
511

612
const __dirname = dirname(fileURLToPath(import.meta.url));
713
const withDirName = (path: string) => join(__dirname, path);
@@ -52,3 +58,110 @@ describe('applyConfigMiddleware', () => {
5258
expect(error?.message).toContain(defaultConfigPath);
5359
});
5460
});
61+
62+
describe('filterPluginsByOnlyPluginsOption', () => {
63+
it('should return all plugins if no onlyPlugins option', async () => {
64+
const plugins = [
65+
{ slug: 'plugin1' },
66+
{ slug: 'plugin2' },
67+
{ slug: 'plugin3' },
68+
];
69+
const filtered = filterPluginsByOnlyPluginsOption(
70+
plugins as CoreConfig['plugins'],
71+
{},
72+
);
73+
expect(filtered).toEqual(plugins);
74+
});
75+
76+
it('should return only plugins with matching slugs', () => {
77+
const plugins = [
78+
{ slug: 'plugin1' },
79+
{ slug: 'plugin2' },
80+
{ slug: 'plugin3' },
81+
];
82+
const filtered = filterPluginsByOnlyPluginsOption(
83+
plugins as CoreConfig['plugins'],
84+
{
85+
onlyPlugins: ['plugin1', 'plugin3'],
86+
},
87+
);
88+
expect(filtered).toEqual([{ slug: 'plugin1' }, { slug: 'plugin3' }]);
89+
});
90+
});
91+
92+
// without the `no-secrets` rule, this would be flagged as a security issue
93+
// eslint-disable-next-line no-secrets/no-secrets
94+
describe('filterCategoryByOnlyPluginsOption', () => {
95+
let logSpy: SpyInstance;
96+
beforeEach(() => {
97+
logSpy = vi.spyOn(console, 'log');
98+
});
99+
100+
afterEach(() => {
101+
logSpy.mockRestore();
102+
});
103+
104+
it('should return all categories if no onlyPlugins option', () => {
105+
const categories = [
106+
{ refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
107+
{ refs: [{ slug: 'plugin3' }] },
108+
];
109+
const filtered = filterCategoryByOnlyPluginsOption(
110+
categories as CoreConfig['categories'],
111+
{},
112+
);
113+
expect(filtered).toEqual(categories);
114+
});
115+
116+
it('should return only categories with matching slugs', () => {
117+
const categories = [
118+
{ refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
119+
{ refs: [{ slug: 'plugin3' }] },
120+
];
121+
const filtered = filterCategoryByOnlyPluginsOption(
122+
categories as CoreConfig['categories'],
123+
{
124+
onlyPlugins: ['plugin1', 'plugin3'],
125+
},
126+
);
127+
expect(filtered).toEqual([{ refs: [{ slug: 'plugin3' }] }]);
128+
});
129+
130+
it('should log if category is ignored', () => {
131+
const categories = [
132+
{ title: 'category1', refs: [{ slug: 'plugin1' }, { slug: 'plugin2' }] },
133+
{ title: 'category2', refs: [{ slug: 'plugin3' }] },
134+
];
135+
filterCategoryByOnlyPluginsOption(categories as CoreConfig['categories'], {
136+
onlyPlugins: ['plugin1', 'plugin3'],
137+
});
138+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"category1"'));
139+
});
140+
});
141+
142+
describe('validateOnlyPluginsOption', () => {
143+
let logSpy: SpyInstance;
144+
beforeEach(() => {
145+
logSpy = vi.spyOn(console, 'log');
146+
});
147+
148+
afterEach(() => {
149+
logSpy.mockRestore();
150+
});
151+
152+
it('should log if onlyPlugins option contains non-existing plugin', () => {
153+
const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }];
154+
validateOnlyPluginsOption(plugins as CoreConfig['plugins'], {
155+
onlyPlugins: ['plugin1', 'plugin3'],
156+
});
157+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"plugin3"'));
158+
});
159+
160+
it('should not log if onlyPlugins option contains existing plugin', () => {
161+
const plugins = [{ slug: 'plugin1' }, { slug: 'plugin2' }];
162+
validateOnlyPluginsOption(plugins as CoreConfig['plugins'], {
163+
onlyPlugins: ['plugin1'],
164+
});
165+
expect(logSpy).not.toHaveBeenCalled();
166+
});
167+
});
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,100 @@
1+
import chalk from 'chalk';
12
import { readCodePushupConfig } from '@code-pushup/core';
23
import { CoreConfig } from '@code-pushup/models';
34
import { GeneralCliOptions } from './model';
5+
import { OnlyPluginsOptions } from './only-plugins-options';
46

57
export async function configMiddleware<
6-
T extends Partial<GeneralCliOptions & CoreConfig>,
8+
T extends Partial<GeneralCliOptions & CoreConfig & OnlyPluginsOptions>,
79
>(processArgs: T) {
810
const args = processArgs as T;
911
const { config, ...cliOptions } = args as GeneralCliOptions &
10-
Required<CoreConfig>;
12+
Required<CoreConfig> &
13+
OnlyPluginsOptions;
1114
const importedRc = await readCodePushupConfig(config);
12-
const parsedProcessArgs: CoreConfig & GeneralCliOptions = {
13-
config,
14-
progress: cliOptions.progress,
15-
verbose: cliOptions.verbose,
16-
upload: {
17-
...importedRc?.upload,
18-
...cliOptions?.upload,
19-
},
20-
persist: {
21-
...importedRc.persist,
22-
...cliOptions?.persist,
23-
},
24-
plugins: importedRc.plugins,
25-
categories: importedRc.categories,
26-
};
15+
16+
validateOnlyPluginsOption(importedRc.plugins, cliOptions);
17+
18+
const parsedProcessArgs: CoreConfig & GeneralCliOptions & OnlyPluginsOptions =
19+
{
20+
config,
21+
progress: cliOptions.progress,
22+
verbose: cliOptions.verbose,
23+
upload: {
24+
...importedRc?.upload,
25+
...cliOptions?.upload,
26+
},
27+
persist: {
28+
...importedRc.persist,
29+
...cliOptions?.persist,
30+
},
31+
plugins: filterPluginsByOnlyPluginsOption(importedRc.plugins, cliOptions),
32+
categories: filterCategoryByOnlyPluginsOption(
33+
importedRc.categories,
34+
cliOptions,
35+
),
36+
onlyPlugins: cliOptions.onlyPlugins,
37+
};
2738

2839
return parsedProcessArgs;
2940
}
41+
42+
export function filterPluginsByOnlyPluginsOption(
43+
plugins: CoreConfig['plugins'],
44+
{ onlyPlugins }: { onlyPlugins?: string[] },
45+
): CoreConfig['plugins'] {
46+
if (!onlyPlugins?.length) {
47+
return plugins;
48+
}
49+
return plugins.filter(plugin => onlyPlugins.includes(plugin.slug));
50+
}
51+
52+
// skip the whole category if it has at least one skipped plugin ref
53+
// see https://github.com/code-pushup/cli/pull/246#discussion_r1392274281
54+
export function filterCategoryByOnlyPluginsOption(
55+
categories: CoreConfig['categories'],
56+
{ onlyPlugins }: { onlyPlugins?: string[] },
57+
): CoreConfig['categories'] {
58+
if (!onlyPlugins?.length) {
59+
return categories;
60+
}
61+
62+
return categories.filter(category =>
63+
category.refs.every(ref => {
64+
const isNotSkipped = onlyPlugins.includes(ref.slug);
65+
66+
if (!isNotSkipped) {
67+
console.log(
68+
`${chalk.yellow('⚠')} Category "${
69+
category.title
70+
}" is ignored because it references audits from skipped plugin "${
71+
ref.slug
72+
}"`,
73+
);
74+
}
75+
76+
return isNotSkipped;
77+
}),
78+
);
79+
}
80+
81+
export function validateOnlyPluginsOption(
82+
plugins: CoreConfig['plugins'],
83+
{ onlyPlugins }: { onlyPlugins?: string[] },
84+
): void {
85+
const missingPlugins = onlyPlugins?.length
86+
? onlyPlugins.filter(plugin => !plugins.some(({ slug }) => slug === plugin))
87+
: [];
88+
89+
if (missingPlugins.length) {
90+
console.log(
91+
`${chalk.yellow(
92+
'⚠',
93+
)} The --onlyPlugin argument references plugins with "${missingPlugins.join(
94+
'", "',
95+
)}" slugs, but no such plugin is present in the configuration. Expected one of the following plugin slugs: "${plugins
96+
.map(({ slug }) => slug)
97+
.join('", "')}".`,
98+
);
99+
}
100+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect } from 'vitest';
2+
import { filterKebabCaseKeys } from './filter-kebab-case-keys';
3+
4+
describe('filterKebabCaseKeys', () => {
5+
it('should filter kebab-case keys', () => {
6+
const obj = {
7+
'kebab-case': 'value',
8+
camelCase: 'value',
9+
snake_case: 'value',
10+
};
11+
const filtered = filterKebabCaseKeys(obj);
12+
expect(filtered).toEqual({
13+
camelCase: 'value',
14+
snake_case: 'value',
15+
});
16+
});
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function filterKebabCaseKeys<T extends Record<string, unknown>>(
2+
obj: T,
3+
): T {
4+
const newObj: Record<string, unknown> = {};
5+
6+
Object.keys(obj).forEach(key => {
7+
if (key.includes('-')) {
8+
return;
9+
}
10+
newObj[key] = obj[key];
11+
});
12+
13+
return newObj as T;
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Options } from 'yargs';
2+
3+
export const onlyPluginsOption: Options = {
4+
describe: 'List of plugins to run. If not set all plugins are run.',
5+
type: 'array',
6+
default: [],
7+
coerce: (arg: string[]) => arg.flatMap(v => v.split(',')),
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface OnlyPluginsOptions {
2+
onlyPlugins: string[];
3+
}

packages/cli/src/lib/print-config/command-object.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { CommandModule } from 'yargs';
2+
import { filterKebabCaseKeys } from '../implementation/filter-kebab-case-keys';
3+
import { onlyPluginsOption } from '../implementation/only-config-option';
24

35
export function yargsConfigCommandObject() {
46
const command = 'print-config';
57
return {
68
command,
79
describe: 'Print config',
8-
handler: args => {
10+
builder: {
11+
onlyPlugins: onlyPluginsOption,
12+
},
13+
handler: yargsArgs => {
914
// eslint-disable-next-line @typescript-eslint/no-unused-vars
10-
const { _, $0, ...cleanArgs } = args;
15+
const { _, $0, ...args } = yargsArgs;
16+
// it is important to filter out kebab case keys
17+
// because yargs duplicates options in camel case and kebab case
18+
const cleanArgs = filterKebabCaseKeys(args);
1119
console.log(JSON.stringify(cleanArgs, null, 2));
1220
},
1321
} satisfies CommandModule;

0 commit comments

Comments
 (0)