diff --git a/e2e/plugin-typescript-e2e/eslint.config.js b/e2e/plugin-typescript-e2e/eslint.config.js new file mode 100644 index 000000000..2656b27cb --- /dev/null +++ b/e2e/plugin-typescript-e2e/eslint.config.js @@ -0,0 +1,12 @@ +import tseslint from 'typescript-eslint'; +import baseConfig from '../../eslint.config.js'; + +export default tseslint.config(...baseConfig, { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts new file mode 100644 index 000000000..1530956b7 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/code-pushup.config.ts @@ -0,0 +1,16 @@ +import type { CoreConfig } from '@code-pushup/models'; +import { + getCategoryRefsFromGroups, + typescriptPlugin, +} from '@code-pushup/typescript-plugin'; + +export default { + plugins: [await typescriptPlugin()], + categories: [ + { + slug: 'typescript-quality', + title: 'Typescript', + refs: getCategoryRefsFromGroups(), + }, + ], +} satisfies CoreConfig; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts new file mode 100644 index 000000000..20f47ca5d --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/exclude/utils.ts @@ -0,0 +1,3 @@ +export function test() { + return 'test'; +} diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts new file mode 100644 index 000000000..5ba0acd92 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/1-syntax-errors.ts @@ -0,0 +1 @@ +const a = { ; // Error: TS1136: Property assignment expected diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts new file mode 100644 index 000000000..f29009cea --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/2-semantic-errors.ts @@ -0,0 +1,7 @@ +// 2683 - NoImplicitThis: 'this' implicitly has type 'any'. +function noImplicitThisTS2683() { + console.log(this.value); // Error 2683 +} + +// 2531 - StrictNullChecks: Object is possibly 'null'. +const strictNullChecksTS2531: string = null; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts new file mode 100644 index 000000000..444811dfb --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/4-languale-service.ts @@ -0,0 +1,6 @@ +class Standalone { + override method() { // Error: TS4114 - 'override' modifier can only be used in a class derived from a base class. + console.log("Standalone method"); + } +} +const s = Standalone; diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts new file mode 100644 index 000000000..c5388fe05 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/src/6-configuration-errors.ts @@ -0,0 +1,3 @@ +import { test } from '../exclude/utils'; + +// TS6059:: File 'exclude/utils.ts' is not under 'rootDir' '.../configuration-errors'. 'rootDir' is expected to contain all source files. diff --git a/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json new file mode 100644 index 000000000..b9dd367e9 --- /dev/null +++ b/e2e/plugin-typescript-e2e/mocks/fixtures/default-setup/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "target": "ES6", + "module": "CommonJS", + "strict": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"] +} diff --git a/e2e/plugin-typescript-e2e/project.json b/e2e/plugin-typescript-e2e/project.json new file mode 100644 index 000000000..4ec3f7c8a --- /dev/null +++ b/e2e/plugin-typescript-e2e/project.json @@ -0,0 +1,23 @@ +{ + "name": "plugin-typescript-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "e2e/plugin-typescript-e2e/src", + "projectType": "application", + "targets": { + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["e2e/plugin-typescript-e2e/**/*.ts"] + } + }, + "e2e": { + "executor": "@nx/vite:test", + "options": { + "configFile": "e2e/plugin-typescript-e2e/vite.config.e2e.ts" + } + } + }, + "implicitDependencies": ["cli", "plugin-typescript"], + "tags": ["scope:plugin", "type:e2e"] +} diff --git a/e2e/plugin-typescript-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/plugin-typescript-e2e/tests/__snapshots__/collect.e2e.test.ts.snap new file mode 100644 index 000000000..56fb612cb --- /dev/null +++ b/e2e/plugin-typescript-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -0,0 +1,218 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PLUGIN collect report with typescript-plugin NPM package > should run plugin over CLI and creates report.json 1`] = ` +{ + "categories": [ + { + "refs": [ + { + "plugin": "typescript", + "slug": "problems", + "type": "group", + "weight": 1, + }, + { + "plugin": "typescript", + "slug": "ts-configuration", + "type": "group", + "weight": 1, + }, + { + "plugin": "typescript", + "slug": "miscellaneous", + "type": "group", + "weight": 1, + }, + ], + "slug": "typescript-quality", + "title": "Typescript", + }, + ], + "packageName": "@code-pushup/core", + "plugins": [ + { + "audits": [ + { + "description": "Errors that occur during parsing and lexing of TypeScript source code", + "details": { + "issues": [ + { + "message": "TS1136: Property assignment expected.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/1-syntax-errors.ts", + "position": { + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "1 issue", + "score": 0, + "slug": "syntax-errors", + "title": "Syntax errors", + "value": 1, + }, + { + "description": "Errors that occur during type checking and type inference", + "details": { + "issues": [ + { + "message": "TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/2-semantic-errors.ts", + "position": { + "startLine": 3, + }, + }, + }, + { + "message": "TS2322: Type 'null' is not assignable to type 'string'.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/2-semantic-errors.ts", + "position": { + "startLine": 7, + }, + }, + }, + ], + }, + "displayValue": "2 issues", + "score": 0, + "slug": "semantic-errors", + "title": "Semantic errors", + "value": 2, + }, + { + "description": "Errors that occur during TypeScript language service operations", + "details": { + "issues": [ + { + "message": "TS4112: This member cannot have an 'override' modifier because its containing class 'Standalone' does not extend another class.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/4-languale-service.ts", + "position": { + "startLine": 2, + }, + }, + }, + ], + }, + "displayValue": "1 issue", + "score": 0, + "slug": "declaration-and-language-service-errors", + "title": "Declaration and language service errors", + "value": 1, + }, + { + "description": "Errors that occur during TypeScript internal operations", + "displayValue": "0 issues", + "score": 1, + "slug": "internal-errors", + "title": "Internal errors", + "value": 0, + }, + { + "description": "Errors that occur when parsing TypeScript configuration files", + "details": { + "issues": [ + { + "message": "TS6059: File './exclude/utils.ts' is not under 'rootDir' 'src'. 'rootDir' is expected to contain all source files.", + "severity": "error", + "source": { + "file": "tmp/e2e/plugin-typescript-e2e/src/6-configuration-errors.ts", + "position": { + "startLine": 1, + }, + }, + }, + ], + }, + "displayValue": "1 issue", + "score": 0, + "slug": "configuration-errors", + "title": "Configuration errors", + "value": 1, + }, + { + "description": "Errors related to no implicit any compiler option", + "displayValue": "0 issues", + "score": 1, + "slug": "no-implicit-any-errors", + "title": "No implicit any errors", + "value": 0, + }, + { + "description": "Errors that do not match any known TypeScript error code", + "displayValue": "0 issues", + "score": 1, + "slug": "unknown-codes", + "title": "Unknown codes", + "value": 0, + }, + ], + "description": "Official Code PushUp Typescript plugin.", + "docsUrl": "https://www.npmjs.com/package/@code-pushup/typescript-plugin/", + "groups": [ + { + "description": "Syntax, semantic, and internal compiler errors are critical for identifying and preventing bugs.", + "refs": [ + { + "slug": "syntax-errors", + "weight": 1, + }, + { + "slug": "semantic-errors", + "weight": 1, + }, + { + "slug": "no-implicit-any-errors", + "weight": 1, + }, + ], + "slug": "problems", + "title": "Problems", + }, + { + "description": "TypeScript configuration and options errors ensure correct project setup, reducing risks from misconfiguration.", + "refs": [ + { + "slug": "configuration-errors", + "weight": 1, + }, + ], + "slug": "ts-configuration", + "title": "Configuration", + }, + { + "description": "Errors that do not bring any specific value to the developer, but are still useful to know.", + "refs": [ + { + "slug": "unknown-codes", + "weight": 1, + }, + { + "slug": "internal-errors", + "weight": 1, + }, + { + "slug": "declaration-and-language-service-errors", + "weight": 1, + }, + ], + "slug": "miscellaneous", + "title": "Miscellaneous", + }, + ], + "icon": "typescript", + "packageName": "@code-pushup/typescript-plugin", + "slug": "typescript", + "title": "Typescript", + }, + ], +} +`; diff --git a/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts b/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts new file mode 100644 index 000000000..b5db4dca1 --- /dev/null +++ b/e2e/plugin-typescript-e2e/tests/collect.e2e.test.ts @@ -0,0 +1,73 @@ +import { cp } from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, beforeAll, expect } from 'vitest'; +import { type Report, reportSchema } from '@code-pushup/models'; +import { nxTargetProject } from '@code-pushup/test-nx-utils'; +import { + E2E_ENVIRONMENTS_DIR, + TEST_OUTPUT_DIR, + omitVariableReportData, + removeColorCodes, + teardownTestFolder, +} from '@code-pushup/test-utils'; +import { executeProcess, readJsonFile } from '@code-pushup/utils'; + +describe('PLUGIN collect report with typescript-plugin NPM package', () => { + const envRoot = path.join(E2E_ENVIRONMENTS_DIR, nxTargetProject()); + const distRoot = path.join(envRoot, TEST_OUTPUT_DIR); + + const fixturesDir = path.join( + 'e2e', + nxTargetProject(), + 'mocks', + 'fixtures', + 'default-setup', + ); + + beforeAll(async () => { + await cp(fixturesDir, envRoot, { recursive: true }); + }); + + afterAll(async () => { + await teardownTestFolder(distRoot); + }); + + it('should run plugin over CLI and creates report.json', async () => { + const outputDir = path.join( + path.relative(envRoot, distRoot), + 'create-report', + '.code-pushup', + ); + + const { code, stdout } = await executeProcess({ + command: 'npx', + // verbose exposes audits with perfect scores that are hidden in the default stdout + args: [ + '@code-pushup/cli', + 'collect', + '--no-progress', + '--verbose', + `--persist.outputDir=${outputDir}`, + ], + cwd: envRoot, + }); + + expect(code).toBe(0); + const cleanStdout = removeColorCodes(stdout); + + expect(cleanStdout).toMatch(/● Semantic errors\s+\d+ issue/); + expect(cleanStdout).toMatch(/● Configuration errors\s+\d+ issue/); + expect(cleanStdout).toMatch( + /● Declaration and language service errors\s+\d+ issue/, + ); + expect(cleanStdout).toMatch(/● Syntax errors\s+\d+ issue/); + expect(cleanStdout).toMatch(/● Internal errors\s+\d+ issue/); + expect(cleanStdout).toMatch(/● No implicit any errors\s+\d+ issue/); + + const reportJson = await readJsonFile( + path.join(envRoot, outputDir, 'report.json'), + ); + expect(() => reportSchema.parse(reportJson)).not.toThrow(); + expect(omitVariableReportData(reportJson)).toMatchSnapshot(); + }); +}); diff --git a/e2e/plugin-typescript-e2e/tsconfig.json b/e2e/plugin-typescript-e2e/tsconfig.json new file mode 100644 index 000000000..f5a2f890a --- /dev/null +++ b/e2e/plugin-typescript-e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/e2e/plugin-typescript-e2e/tsconfig.test.json b/e2e/plugin-typescript-e2e/tsconfig.test.json new file mode 100644 index 000000000..10c7f79de --- /dev/null +++ b/e2e/plugin-typescript-e2e/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"], + "target": "ES2020" + }, + "include": [ + "vite.config.e2e.ts", + "tests/**/*.e2e.test.ts", + "tests/**/*.d.ts", + "mocks/**/*.ts" + ] +} diff --git a/e2e/plugin-typescript-e2e/vite.config.e2e.ts b/e2e/plugin-typescript-e2e/vite.config.e2e.ts new file mode 100644 index 000000000..8f30d049b --- /dev/null +++ b/e2e/plugin-typescript-e2e/vite.config.e2e.ts @@ -0,0 +1,26 @@ +/// +import { defineConfig } from 'vite'; +import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/plugin-typescript-e2e', + test: { + reporters: ['basic'], + testTimeout: 20_000, + globals: true, + alias: tsconfigPathAliases(), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: '../../coverage/plugin-typescript-e2e/e2e-tests', + exclude: ['mocks/**', '**/types.ts'], + }, + cache: { + dir: '../../node_modules/.vitest', + }, + environment: 'node', + include: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['../../testing/test-setup/src/lib/reset.mocks.ts'], + }, +}); diff --git a/packages/plugin-typescript/src/index.ts b/packages/plugin-typescript/src/index.ts index de384e130..7dbcf52bb 100644 --- a/packages/plugin-typescript/src/index.ts +++ b/packages/plugin-typescript/src/index.ts @@ -1,6 +1,6 @@ export { TYPESCRIPT_PLUGIN_SLUG } from './lib/constants.js'; export { typescriptPlugin } from './lib/typescript-plugin.js'; -export { getCategories } from './lib/utils.js'; +export { getCategories, getCategoryRefsFromGroups } from './lib/utils.js'; export { type TypescriptPluginConfig, type TypescriptPluginOptions, diff --git a/packages/plugin-typescript/src/lib/runner/utils.ts b/packages/plugin-typescript/src/lib/runner/utils.ts index ab22e6d1a..da87f2327 100644 --- a/packages/plugin-typescript/src/lib/runner/utils.ts +++ b/packages/plugin-typescript/src/lib/runner/utils.ts @@ -46,6 +46,19 @@ export function getSeverity(category: DiagnosticCategory): Issue['severity'] { } } +/** + * Format issue message from the TypeScript diagnostic. + * @param diag - The TypeScript diagnostic. + * @returns The issue message. + */ +export function getMessage(diag: Diagnostic): string { + const flattened = flattenDiagnosticMessageText(diag.messageText, '\n'); + const text = flattened + .replace(process.cwd(), '.') + .replace(process.cwd().replace(/\\/g, '/'), '.'); + return truncateIssueMessage(`TS${diag.code}: ${text}`); +} + /** * Get the issue from the TypeScript diagnostic. * @param diag - The TypeScript diagnostic. @@ -53,11 +66,9 @@ export function getSeverity(category: DiagnosticCategory): Issue['severity'] { * @throws Error if the diagnostic is global (e.g., invalid compiler option). */ export function getIssueFromDiagnostic(diag: Diagnostic) { - const message = `${flattenDiagnosticMessageText(diag.messageText, '\n')}`; - const issue: Issue = { severity: getSeverity(diag.category), - message: truncateIssueMessage(`TS${diag.code}: ${message}`), + message: getMessage(diag), }; // If undefined, the error might be global (e.g., invalid compiler option). diff --git a/packages/plugin-typescript/src/lib/runner/utils.unit.test.ts b/packages/plugin-typescript/src/lib/runner/utils.unit.test.ts index 2bb4a8eb9..f4d601ee5 100644 --- a/packages/plugin-typescript/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-typescript/src/lib/runner/utils.unit.test.ts @@ -94,6 +94,20 @@ describe('getIssueFromDiagnostic', () => { ); }); + it('should replace absolute paths in message', () => { + expect( + getIssueFromDiagnostic({ + ...diagnosticMock, + code: 6059, + messageText: `File '${process.cwd()}/tools/publish.ts' is not under 'rootDir' 'src'. 'rootDir' is expected to contain all source files.`, + }), + ).toStrictEqual( + expect.objectContaining({ + message: `TS6059: File './tools/publish.ts' is not under 'rootDir' 'src'. 'rootDir' is expected to contain all source files.`, + }), + ); + }); + it('should return issue without position if file is undefined', () => { expect( getIssueFromDiagnostic({ ...diagnosticMock, file: undefined }), diff --git a/packages/plugin-typescript/src/lib/utils.ts b/packages/plugin-typescript/src/lib/utils.ts index 059d057f5..6143489e4 100644 --- a/packages/plugin-typescript/src/lib/utils.ts +++ b/packages/plugin-typescript/src/lib/utils.ts @@ -78,9 +78,9 @@ export function getAudits( * @param opt TSPluginOptions * @returns The array of category references */ -export async function getCategoryRefsFromGroups( +export function getCategoryRefsFromGroups( opt?: TypescriptPluginOptions, -): Promise { +): CategoryRef[] { return getGroups(opt).map(({ slug }) => ({ plugin: TYPESCRIPT_PLUGIN_SLUG, slug, @@ -94,9 +94,9 @@ export async function getCategoryRefsFromGroups( * @param opt TSPluginOptions * @returns The array of category references */ -export async function getCategoryRefsFromAudits( +export function getCategoryRefsFromAudits( opt?: TypescriptPluginOptions, -): Promise { +): CategoryRef[] { return AUDITS.filter(filterAuditsBySlug(opt?.onlyAudits)).map(({ slug }) => ({ plugin: TYPESCRIPT_PLUGIN_SLUG, slug, @@ -110,13 +110,13 @@ export const CATEGORY_MAP: Record = { slug: 'type-safety', title: 'Type Safety', description: 'TypeScript diagnostics and type-checking errors', - refs: await getCategoryRefsFromGroups(), + refs: getCategoryRefsFromGroups(), }, 'bug-prevention': { slug: 'bug-prevention', title: 'Bug prevention', description: 'Type checks that find **potential bugs** in your code.', - refs: await getCategoryRefsFromGroups({ + refs: getCategoryRefsFromGroups({ onlyAudits: [ 'syntax-errors', 'semantic-errors', @@ -131,7 +131,7 @@ export const CATEGORY_MAP: Record = { title: 'Miscellaneous', description: 'Errors that do not bring any specific value to the developer, but are still useful to know.', - refs: await getCategoryRefsFromGroups({ + refs: getCategoryRefsFromGroups({ onlyAudits: ['unknown-codes', 'declaration-and-language-service-errors'], }), },