Skip to content

Commit df5bfa1

Browse files
authored
feat(plugin-typescript): add plugin logic (#936)
1 parent afd50e7 commit df5bfa1

9 files changed

+216
-29
lines changed

packages/plugin-typescript/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"type": "module",
2525
"dependencies": {
2626
"@code-pushup/models": "0.59.0",
27-
"@code-pushup/utils": "0.59.0"
27+
"@code-pushup/utils": "0.59.0",
28+
"zod": "^3.23.8"
2829
},
2930
"peerDependencies": {
3031
"typescript": ">=4.0.0"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { z } from 'zod';
2+
import { AUDITS, DEFAULT_TS_CONFIG } from './constants.js';
3+
import type { AuditSlug } from './types.js';
4+
5+
const auditSlugs = AUDITS.map(({ slug }) => slug) as [
6+
AuditSlug,
7+
...AuditSlug[],
8+
];
9+
export const typescriptPluginConfigSchema = z.object({
10+
tsconfig: z
11+
.string({
12+
description: 'Path to a tsconfig file (default is tsconfig.json)',
13+
})
14+
.default(DEFAULT_TS_CONFIG),
15+
onlyAudits: z
16+
.array(z.enum(auditSlugs), {
17+
description: 'Filters TypeScript compiler errors by diagnostic codes',
18+
})
19+
.optional(),
20+
});
21+
22+
export type TypescriptPluginOptions = z.input<
23+
typeof typescriptPluginConfigSchema
24+
>;
25+
export type TypescriptPluginConfig = z.infer<
26+
typeof typescriptPluginConfigSchema
27+
>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
type TypescriptPluginOptions,
4+
typescriptPluginConfigSchema,
5+
} from './schema.js';
6+
7+
describe('typescriptPluginConfigSchema', () => {
8+
const tsconfig = 'tsconfig.json';
9+
10+
it('accepts a empty configuration', () => {
11+
expect(() => typescriptPluginConfigSchema.parse({})).not.toThrow();
12+
});
13+
14+
it('accepts a configuration with tsconfig set', () => {
15+
expect(() =>
16+
typescriptPluginConfigSchema.parse({
17+
tsconfig,
18+
} satisfies TypescriptPluginOptions),
19+
).not.toThrow();
20+
});
21+
22+
it('accepts a configuration with tsconfig and empty onlyAudits', () => {
23+
expect(() =>
24+
typescriptPluginConfigSchema.parse({
25+
tsconfig,
26+
onlyAudits: [],
27+
} satisfies TypescriptPluginOptions),
28+
).not.toThrow();
29+
});
30+
31+
it('accepts a configuration with tsconfig and full onlyAudits', () => {
32+
expect(() =>
33+
typescriptPluginConfigSchema.parse({
34+
tsconfig,
35+
onlyAudits: [
36+
'syntax-errors',
37+
'semantic-errors',
38+
'configuration-errors',
39+
],
40+
} satisfies TypescriptPluginOptions),
41+
).not.toThrow();
42+
});
43+
44+
it('throws for invalid onlyAudits', () => {
45+
expect(() =>
46+
typescriptPluginConfigSchema.parse({
47+
onlyAudits: 123,
48+
}),
49+
).toThrow('invalid_type');
50+
});
51+
52+
it('throws for invalid onlyAudits items', () => {
53+
expect(() =>
54+
typescriptPluginConfigSchema.parse({
55+
tsconfig,
56+
onlyAudits: [123, true],
57+
}),
58+
).toThrow('invalid_type');
59+
});
60+
61+
it('throws for unknown audit slug', () => {
62+
expect(() =>
63+
typescriptPluginConfigSchema.parse({
64+
tsconfig,
65+
onlyAudits: ['unknown-audit'],
66+
}),
67+
).toThrow(/unknown-audit/);
68+
});
69+
});
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
import type { DiagnosticsOptions } from './runner/ts-runner.js';
21
import type { CodeRangeName } from './runner/types.js';
32

43
export type AuditSlug = CodeRangeName;
5-
6-
export type FilterOptions = { onlyAudits?: AuditSlug[] | undefined };
7-
export type TypescriptPluginOptions = Partial<DiagnosticsOptions> &
8-
FilterOptions;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createRequire } from 'node:module';
2+
import type { PluginConfig } from '@code-pushup/models';
3+
import { stringifyError } from '@code-pushup/utils';
4+
import { DEFAULT_TS_CONFIG, TYPESCRIPT_PLUGIN_SLUG } from './constants.js';
5+
import { createRunnerFunction } from './runner/runner.js';
6+
import {
7+
type TypescriptPluginConfig,
8+
type TypescriptPluginOptions,
9+
typescriptPluginConfigSchema,
10+
} from './schema.js';
11+
import { getAudits, getGroups, logSkippedAudits } from './utils.js';
12+
13+
const packageJson = createRequire(import.meta.url)(
14+
'../../package.json',
15+
) as typeof import('../../package.json');
16+
17+
export async function typescriptPlugin(
18+
options?: TypescriptPluginOptions,
19+
): Promise<PluginConfig> {
20+
const { tsconfig = DEFAULT_TS_CONFIG, onlyAudits } = parseOptions(
21+
options ?? {},
22+
);
23+
24+
const filteredAudits = getAudits({ onlyAudits });
25+
const filteredGroups = getGroups({ onlyAudits });
26+
27+
logSkippedAudits(filteredAudits);
28+
29+
return {
30+
slug: TYPESCRIPT_PLUGIN_SLUG,
31+
packageName: packageJson.name,
32+
version: packageJson.version,
33+
title: 'Typescript',
34+
description: 'Official Code PushUp Typescript plugin.',
35+
docsUrl: 'https://www.npmjs.com/package/@code-pushup/typescript-plugin/',
36+
icon: 'typescript',
37+
audits: filteredAudits,
38+
groups: filteredGroups,
39+
runner: createRunnerFunction({
40+
tsconfig,
41+
expectedAudits: filteredAudits,
42+
}),
43+
};
44+
}
45+
46+
function parseOptions(
47+
tsPluginOptions: TypescriptPluginOptions,
48+
): TypescriptPluginConfig {
49+
try {
50+
return typescriptPluginConfigSchema.parse(tsPluginOptions);
51+
} catch (error) {
52+
throw new Error(
53+
`Error parsing TypeScript Plugin options: ${stringifyError(error)}`,
54+
);
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect } from 'vitest';
2+
import { pluginConfigSchema } from '@code-pushup/models';
3+
import { AUDITS, GROUPS } from './constants.js';
4+
import type { TypescriptPluginOptions } from './schema.js';
5+
import { typescriptPlugin } from './typescript-plugin.js';
6+
7+
describe('typescriptPlugin-config-object', () => {
8+
it('should create valid plugin config without options', async () => {
9+
const pluginConfig = await typescriptPlugin();
10+
11+
expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow();
12+
13+
const { audits, groups } = pluginConfig;
14+
expect(audits).toHaveLength(AUDITS.length);
15+
expect(groups).toBeDefined();
16+
expect(groups!).toHaveLength(GROUPS.length);
17+
});
18+
19+
it('should create valid plugin config', async () => {
20+
const pluginConfig = await typescriptPlugin({
21+
tsconfig: 'mocked-away/tsconfig.json',
22+
onlyAudits: ['syntax-errors', 'semantic-errors', 'configuration-errors'],
23+
});
24+
25+
expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow();
26+
27+
const { audits, groups } = pluginConfig;
28+
expect(audits).toHaveLength(3);
29+
expect(groups).toBeDefined();
30+
expect(groups!).toHaveLength(2);
31+
});
32+
33+
it('should throw for invalid valid params', async () => {
34+
await expect(() =>
35+
typescriptPlugin({
36+
tsconfig: 42,
37+
} as unknown as TypescriptPluginOptions),
38+
).rejects.toThrow(/invalid_type/);
39+
});
40+
});

packages/plugin-typescript/src/lib/utils.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import type { CompilerOptions } from 'typescript';
22
import type { Audit, CategoryConfig, CategoryRef } from '@code-pushup/models';
3-
import { kebabCaseToCamelCase } from '@code-pushup/utils';
3+
import { kebabCaseToCamelCase, ui } from '@code-pushup/utils';
44
import { AUDITS, GROUPS, TYPESCRIPT_PLUGIN_SLUG } from './constants.js';
5-
import type { FilterOptions, TypescriptPluginOptions } from './types.js';
5+
import type {
6+
TypescriptPluginConfig,
7+
TypescriptPluginOptions,
8+
} from './schema.js';
69

10+
/**
11+
* It filters the audits by the slugs
12+
*
13+
* @param slugs
14+
*/
715
export function filterAuditsBySlug(slugs?: string[]) {
816
return ({ slug }: { slug: string }) => {
917
if (slugs && slugs.length > 0) {
@@ -58,7 +66,9 @@ export function getGroups(options?: TypescriptPluginOptions) {
5866
})).filter(group => group.refs.length > 0);
5967
}
6068

61-
export function getAudits(options?: FilterOptions) {
69+
export function getAudits(
70+
options?: Pick<TypescriptPluginConfig, 'onlyAudits'>,
71+
) {
6272
return AUDITS.filter(filterAuditsBySlug(options?.onlyAudits));
6373
}
6474

@@ -136,6 +146,6 @@ export function logSkippedAudits(audits: Audit[]) {
136146
audit => !audits.some(filtered => filtered.slug === audit.slug),
137147
).map(audit => kebabCaseToCamelCase(audit.slug));
138148
if (skippedAudits.length > 0) {
139-
console.warn(`Skipped audits: [${skippedAudits.join(', ')}]`);
149+
ui().logger.info(`Skipped audits: [${skippedAudits.join(', ')}]`);
140150
}
141151
}

packages/plugin-typescript/src/lib/utils.unit.test.ts

+7-19
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { describe, expect, it } from 'vitest';
22
import { type Audit, categoryRefSchema } from '@code-pushup/models';
3+
import { ui } from '@code-pushup/utils';
34
import { AUDITS } from './constants.js';
45
import {
56
filterAuditsByCompilerOptions,
@@ -99,31 +100,21 @@ describe('getCategoryRefsFromGroups', () => {
99100

100101
it('should return all groups as categoryRefs if compiler options are given', async () => {
101102
const categoryRefs = await getCategoryRefsFromGroups({
102-
tsConfigPath: 'tsconfig.json',
103+
tsconfig: 'tsconfig.json',
103104
});
104105
expect(categoryRefs).toHaveLength(3);
105106
});
106107

107108
it('should return a subset of all groups as categoryRefs if compiler options contain onlyAudits filter', async () => {
108109
const categoryRefs = await getCategoryRefsFromGroups({
109-
tsConfigPath: 'tsconfig.json',
110+
tsconfig: 'tsconfig.json',
110111
onlyAudits: ['semantic-errors'],
111112
});
112113
expect(categoryRefs).toHaveLength(1);
113114
});
114115
});
115116

116117
describe('logSkippedAudits', () => {
117-
beforeEach(() => {
118-
vi.mock('console', () => ({
119-
warn: vi.fn(),
120-
}));
121-
});
122-
123-
afterEach(() => {
124-
vi.restoreAllMocks();
125-
});
126-
127118
it('should not warn when all audits are included', () => {
128119
logSkippedAudits(AUDITS);
129120

@@ -133,18 +124,15 @@ describe('logSkippedAudits', () => {
133124
it('should warn about skipped audits', () => {
134125
logSkippedAudits(AUDITS.slice(0, -1));
135126

136-
expect(console.warn).toHaveBeenCalledTimes(1);
137-
expect(console.warn).toHaveBeenCalledWith(
127+
expect(ui()).toHaveLogged(
128+
'info',
138129
expect.stringContaining(`Skipped audits: [`),
139130
);
140131
});
141132

142133
it('should camel case the slugs in the audit message', () => {
143134
logSkippedAudits(AUDITS.slice(0, -1));
144135

145-
expect(console.warn).toHaveBeenCalledTimes(1);
146-
expect(console.warn).toHaveBeenCalledWith(
147-
expect.stringContaining(`unknownCodes`),
148-
);
136+
expect(ui()).toHaveLogged('info', expect.stringContaining(`unknownCodes`));
149137
});
150138
});

packages/plugin-typescript/vite.config.unit.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default defineConfig({
2323
globalSetup: ['../../global-setup.ts'],
2424
setupFiles: [
2525
'../../testing/test-setup/src/lib/cliui.mock.ts',
26+
'../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts',
2627
'../../testing/test-setup/src/lib/fs.mock.ts',
2728
'../../testing/test-setup/src/lib/console.mock.ts',
2829
'../../testing/test-setup/src/lib/reset.mocks.ts',

0 commit comments

Comments
 (0)