diff --git a/packages/plugin-typescript/package.json b/packages/plugin-typescript/package.json index d7d9831b7..c8c441b15 100644 --- a/packages/plugin-typescript/package.json +++ b/packages/plugin-typescript/package.json @@ -24,7 +24,8 @@ "type": "module", "dependencies": { "@code-pushup/models": "0.59.0", - "@code-pushup/utils": "0.59.0" + "@code-pushup/utils": "0.59.0", + "zod": "^3.23.8" }, "peerDependencies": { "typescript": ">=4.0.0" diff --git a/packages/plugin-typescript/src/lib/schema.ts b/packages/plugin-typescript/src/lib/schema.ts new file mode 100644 index 000000000..db8a3a395 --- /dev/null +++ b/packages/plugin-typescript/src/lib/schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { AUDITS, DEFAULT_TS_CONFIG } from './constants.js'; +import type { AuditSlug } from './types.js'; + +const auditSlugs = AUDITS.map(({ slug }) => slug) as [ + AuditSlug, + ...AuditSlug[], +]; +export const typescriptPluginConfigSchema = z.object({ + tsconfig: z + .string({ + description: 'Path to a tsconfig file (default is tsconfig.json)', + }) + .default(DEFAULT_TS_CONFIG), + onlyAudits: z + .array(z.enum(auditSlugs), { + description: 'Filters TypeScript compiler errors by diagnostic codes', + }) + .optional(), +}); + +export type TypescriptPluginOptions = z.input< + typeof typescriptPluginConfigSchema +>; +export type TypescriptPluginConfig = z.infer< + typeof typescriptPluginConfigSchema +>; diff --git a/packages/plugin-typescript/src/lib/schema.unit.test.ts b/packages/plugin-typescript/src/lib/schema.unit.test.ts new file mode 100644 index 000000000..b23fe6a89 --- /dev/null +++ b/packages/plugin-typescript/src/lib/schema.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + type TypescriptPluginOptions, + typescriptPluginConfigSchema, +} from './schema.js'; + +describe('typescriptPluginConfigSchema', () => { + const tsconfig = 'tsconfig.json'; + + it('accepts a empty configuration', () => { + expect(() => typescriptPluginConfigSchema.parse({})).not.toThrow(); + }); + + it('accepts a configuration with tsconfig set', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsconfig, + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('accepts a configuration with tsconfig and empty onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsconfig, + onlyAudits: [], + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('accepts a configuration with tsconfig and full onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsconfig, + onlyAudits: [ + 'syntax-errors', + 'semantic-errors', + 'configuration-errors', + ], + } satisfies TypescriptPluginOptions), + ).not.toThrow(); + }); + + it('throws for invalid onlyAudits', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + onlyAudits: 123, + }), + ).toThrow('invalid_type'); + }); + + it('throws for invalid onlyAudits items', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsconfig, + onlyAudits: [123, true], + }), + ).toThrow('invalid_type'); + }); + + it('throws for unknown audit slug', () => { + expect(() => + typescriptPluginConfigSchema.parse({ + tsconfig, + onlyAudits: ['unknown-audit'], + }), + ).toThrow(/unknown-audit/); + }); +}); diff --git a/packages/plugin-typescript/src/lib/types.ts b/packages/plugin-typescript/src/lib/types.ts index 7345bb3f0..7c5a6f3e2 100644 --- a/packages/plugin-typescript/src/lib/types.ts +++ b/packages/plugin-typescript/src/lib/types.ts @@ -1,8 +1,3 @@ -import type { DiagnosticsOptions } from './runner/ts-runner.js'; import type { CodeRangeName } from './runner/types.js'; export type AuditSlug = CodeRangeName; - -export type FilterOptions = { onlyAudits?: AuditSlug[] | undefined }; -export type TypescriptPluginOptions = Partial & - FilterOptions; diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.ts b/packages/plugin-typescript/src/lib/typescript-plugin.ts new file mode 100644 index 000000000..345f7855d --- /dev/null +++ b/packages/plugin-typescript/src/lib/typescript-plugin.ts @@ -0,0 +1,56 @@ +import { createRequire } from 'node:module'; +import type { PluginConfig } from '@code-pushup/models'; +import { stringifyError } from '@code-pushup/utils'; +import { DEFAULT_TS_CONFIG, TYPESCRIPT_PLUGIN_SLUG } from './constants.js'; +import { createRunnerFunction } from './runner/runner.js'; +import { + type TypescriptPluginConfig, + type TypescriptPluginOptions, + typescriptPluginConfigSchema, +} from './schema.js'; +import { getAudits, getGroups, logSkippedAudits } from './utils.js'; + +const packageJson = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +export async function typescriptPlugin( + options?: TypescriptPluginOptions, +): Promise { + const { tsconfig = DEFAULT_TS_CONFIG, onlyAudits } = parseOptions( + options ?? {}, + ); + + const filteredAudits = getAudits({ onlyAudits }); + const filteredGroups = getGroups({ onlyAudits }); + + logSkippedAudits(filteredAudits); + + return { + slug: TYPESCRIPT_PLUGIN_SLUG, + packageName: packageJson.name, + version: packageJson.version, + title: 'Typescript', + description: 'Official Code PushUp Typescript plugin.', + docsUrl: 'https://www.npmjs.com/package/@code-pushup/typescript-plugin/', + icon: 'typescript', + audits: filteredAudits, + groups: filteredGroups, + runner: createRunnerFunction({ + tsconfig, + expectedAudits: filteredAudits, + }), + }; +} + +function parseOptions( + tsPluginOptions: TypescriptPluginOptions, +): TypescriptPluginConfig { + try { + return typescriptPluginConfigSchema.parse(tsPluginOptions); + } catch (error) { + throw new Error( + `Error parsing TypeScript Plugin options: ${stringifyError(error)}`, + ); + } +} diff --git a/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts new file mode 100644 index 000000000..838298b7d --- /dev/null +++ b/packages/plugin-typescript/src/lib/typescript-plugin.unit.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'vitest'; +import { pluginConfigSchema } from '@code-pushup/models'; +import { AUDITS, GROUPS } from './constants.js'; +import type { TypescriptPluginOptions } from './schema.js'; +import { typescriptPlugin } from './typescript-plugin.js'; + +describe('typescriptPlugin-config-object', () => { + it('should create valid plugin config without options', async () => { + const pluginConfig = await typescriptPlugin(); + + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + + const { audits, groups } = pluginConfig; + expect(audits).toHaveLength(AUDITS.length); + expect(groups).toBeDefined(); + expect(groups!).toHaveLength(GROUPS.length); + }); + + it('should create valid plugin config', async () => { + const pluginConfig = await typescriptPlugin({ + tsconfig: 'mocked-away/tsconfig.json', + onlyAudits: ['syntax-errors', 'semantic-errors', 'configuration-errors'], + }); + + expect(() => pluginConfigSchema.parse(pluginConfig)).not.toThrow(); + + const { audits, groups } = pluginConfig; + expect(audits).toHaveLength(3); + expect(groups).toBeDefined(); + expect(groups!).toHaveLength(2); + }); + + it('should throw for invalid valid params', async () => { + await expect(() => + typescriptPlugin({ + tsconfig: 42, + } as unknown as TypescriptPluginOptions), + ).rejects.toThrow(/invalid_type/); + }); +}); diff --git a/packages/plugin-typescript/src/lib/utils.ts b/packages/plugin-typescript/src/lib/utils.ts index a678fa5ac..059d057f5 100644 --- a/packages/plugin-typescript/src/lib/utils.ts +++ b/packages/plugin-typescript/src/lib/utils.ts @@ -1,9 +1,17 @@ import type { CompilerOptions } from 'typescript'; import type { Audit, CategoryConfig, CategoryRef } from '@code-pushup/models'; -import { kebabCaseToCamelCase } from '@code-pushup/utils'; +import { kebabCaseToCamelCase, ui } from '@code-pushup/utils'; import { AUDITS, GROUPS, TYPESCRIPT_PLUGIN_SLUG } from './constants.js'; -import type { FilterOptions, TypescriptPluginOptions } from './types.js'; +import type { + TypescriptPluginConfig, + TypescriptPluginOptions, +} from './schema.js'; +/** + * It filters the audits by the slugs + * + * @param slugs + */ export function filterAuditsBySlug(slugs?: string[]) { return ({ slug }: { slug: string }) => { if (slugs && slugs.length > 0) { @@ -58,7 +66,9 @@ export function getGroups(options?: TypescriptPluginOptions) { })).filter(group => group.refs.length > 0); } -export function getAudits(options?: FilterOptions) { +export function getAudits( + options?: Pick, +) { return AUDITS.filter(filterAuditsBySlug(options?.onlyAudits)); } @@ -136,6 +146,6 @@ export function logSkippedAudits(audits: Audit[]) { audit => !audits.some(filtered => filtered.slug === audit.slug), ).map(audit => kebabCaseToCamelCase(audit.slug)); if (skippedAudits.length > 0) { - console.warn(`Skipped audits: [${skippedAudits.join(', ')}]`); + ui().logger.info(`Skipped audits: [${skippedAudits.join(', ')}]`); } } diff --git a/packages/plugin-typescript/src/lib/utils.unit.test.ts b/packages/plugin-typescript/src/lib/utils.unit.test.ts index 21f4b96eb..274ca794c 100644 --- a/packages/plugin-typescript/src/lib/utils.unit.test.ts +++ b/packages/plugin-typescript/src/lib/utils.unit.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { type Audit, categoryRefSchema } from '@code-pushup/models'; +import { ui } from '@code-pushup/utils'; import { AUDITS } from './constants.js'; import { filterAuditsByCompilerOptions, @@ -99,14 +100,14 @@ describe('getCategoryRefsFromGroups', () => { it('should return all groups as categoryRefs if compiler options are given', async () => { const categoryRefs = await getCategoryRefsFromGroups({ - tsConfigPath: 'tsconfig.json', + tsconfig: 'tsconfig.json', }); expect(categoryRefs).toHaveLength(3); }); it('should return a subset of all groups as categoryRefs if compiler options contain onlyAudits filter', async () => { const categoryRefs = await getCategoryRefsFromGroups({ - tsConfigPath: 'tsconfig.json', + tsconfig: 'tsconfig.json', onlyAudits: ['semantic-errors'], }); expect(categoryRefs).toHaveLength(1); @@ -114,16 +115,6 @@ describe('getCategoryRefsFromGroups', () => { }); describe('logSkippedAudits', () => { - beforeEach(() => { - vi.mock('console', () => ({ - warn: vi.fn(), - })); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - it('should not warn when all audits are included', () => { logSkippedAudits(AUDITS); @@ -133,8 +124,8 @@ describe('logSkippedAudits', () => { it('should warn about skipped audits', () => { logSkippedAudits(AUDITS.slice(0, -1)); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( + expect(ui()).toHaveLogged( + 'info', expect.stringContaining(`Skipped audits: [`), ); }); @@ -142,9 +133,6 @@ describe('logSkippedAudits', () => { it('should camel case the slugs in the audit message', () => { logSkippedAudits(AUDITS.slice(0, -1)); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining(`unknownCodes`), - ); + expect(ui()).toHaveLogged('info', expect.stringContaining(`unknownCodes`)); }); }); diff --git a/packages/plugin-typescript/vite.config.unit.ts b/packages/plugin-typescript/vite.config.unit.ts index e7a8783f0..f4b1337f9 100644 --- a/packages/plugin-typescript/vite.config.unit.ts +++ b/packages/plugin-typescript/vite.config.unit.ts @@ -23,6 +23,7 @@ export default defineConfig({ globalSetup: ['../../global-setup.ts'], setupFiles: [ '../../testing/test-setup/src/lib/cliui.mock.ts', + '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts',