diff --git a/e2e/cli-e2e/tests/print-config.e2e.test.ts b/e2e/cli-e2e/tests/print-config.e2e.test.ts index 630d3bb2a..5cb346701 100644 --- a/e2e/cli-e2e/tests/print-config.e2e.test.ts +++ b/e2e/cli-e2e/tests/print-config.e2e.test.ts @@ -47,7 +47,7 @@ describe('CLI print-config', () => { 'print-config', '--output=config.json', `--config=${configFilePath(ext)}`, - '--tsconfig=tsconfig.base.json', + `--tsconfig=${path.join(process.cwd(), 'tsconfig.base.json')}`, '--persist.outputDir=output-dir', '--persist.format=md', `--persist.filename=${ext}-report`, @@ -64,7 +64,7 @@ describe('CLI print-config', () => { expect(JSON.parse(output)).toEqual( expect.objectContaining({ config: expect.stringContaining(`code-pushup.config.${ext}`), - tsconfig: 'tsconfig.base.json', + tsconfig: path.join(process.cwd(), 'tsconfig.base.json'), plugins: [ expect.objectContaining({ slug: 'dummy-plugin', diff --git a/package-lock.json b/package-lock.json index b9c0986ff..72b15fa39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@nx/react": "22.3.3", "@nx/vite": "22.3.3", "@nx/workspace": "22.3.3", + "@push-based/jiti-tsc": "^0.0.2", "@push-based/nx-verdaccio": "0.0.7", "@swc-node/register": "1.9.2", "@swc/cli": "0.6.0", @@ -92,7 +93,7 @@ "husky": "^8.0.0", "inquirer": "^9.3.7", "jest-extended": "^6.0.0", - "jiti": "2.4.2", + "jiti": "^2.6.1", "jsdom": "~24.0.0", "jsonc-eslint-parser": "^2.4.0", "knip": "^5.33.3", @@ -4617,6 +4618,16 @@ "node": ">=8" } }, + "node_modules/@module-federation/cli/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@module-federation/cli/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6316,6 +6327,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@push-based/jiti-tsc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@push-based/jiti-tsc/-/jiti-tsc-0.0.2.tgz", + "integrity": "sha512-9K8IYbZePUzPHDkA6tuOukFcJT6xu6bbFtWaws0CtVsmQaeqyRrKuSYFMl73OIee7oOXUOZS2QMIZp3ky6sh/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^3.3.2", + "jiti": "^2.4.2", + "ora": "^9.0.0", + "tslib": "^2.8.1", + "typescript": "5.7.3" + }, + "bin": { + "jiti-tsc": "src/bin/bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@push-based/nx-verdaccio": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@push-based/nx-verdaccio/-/nx-verdaccio-0.0.7.tgz", @@ -22220,9 +22254,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index a7b55cf4f..c4455978d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@nx/react": "22.3.3", "@nx/vite": "22.3.3", "@nx/workspace": "22.3.3", + "@push-based/jiti-tsc": "^0.0.2", "@push-based/nx-verdaccio": "0.0.7", "@swc-node/register": "1.9.2", "@swc/cli": "0.6.0", @@ -102,7 +103,7 @@ "husky": "^8.0.0", "inquirer": "^9.3.7", "jest-extended": "^6.0.0", - "jiti": "2.4.2", + "jiti": "^2.6.1", "jsdom": "~24.0.0", "jsonc-eslint-parser": "^2.4.0", "knip": "^5.33.3", diff --git a/packages/cli/mocks/fixtures/configs/code-pushup.config.js b/packages/cli/mocks/fixtures/configs/code-pushup.config.js new file mode 100644 index 000000000..9f1661d4d --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/code-pushup.config.js @@ -0,0 +1,43 @@ +export default { + upload: { + organization: 'code-pushup', + project: 'cli-js', + apiKey: 'e2e-api-key', + server: 'https://e2e.com/api', + }, + categories: [ + { + slug: 'category-1', + title: 'Category 1', + refs: [ + { + type: 'audit', + plugin: 'node', + slug: 'node-version', + weight: 1, + }, + ], + }, + ], + plugins: [ + { + audits: [ + { + slug: 'node-version', + title: 'Node version', + description: 'prints node version to file', + docsUrl: 'https://nodejs.org/', + }, + ], + runner: { + command: 'node', + args: ['-v'], + outputFile: 'output.json', + }, + groups: [], + slug: 'node', + title: 'Node.js', + icon: 'javascript', + }, + ], +}; diff --git a/packages/cli/mocks/fixtures/configs/code-pushup.config.mjs b/packages/cli/mocks/fixtures/configs/code-pushup.config.mjs new file mode 100644 index 000000000..d7f531533 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/code-pushup.config.mjs @@ -0,0 +1,43 @@ +export default { + upload: { + organization: 'code-pushup', + project: 'cli-mjs', + apiKey: 'e2e-api-key', + server: 'https://e2e.com/api', + }, + categories: [ + { + slug: 'category-1', + title: 'Category 1', + refs: [ + { + type: 'audit', + plugin: 'node', + slug: 'node-version', + weight: 1, + }, + ], + }, + ], + plugins: [ + { + audits: [ + { + slug: 'node-version', + title: 'Node version', + description: 'prints node version to file', + docsUrl: 'https://nodejs.org/', + }, + ], + runner: { + command: 'node', + args: ['-v'], + outputFile: 'output.json', + }, + groups: [], + slug: 'node', + title: 'Node.js', + icon: 'javascript', + }, + ], +}; diff --git a/packages/cli/mocks/fixtures/configs/code-pushup.config.ts b/packages/cli/mocks/fixtures/configs/code-pushup.config.ts new file mode 100644 index 000000000..aad20f9b6 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/code-pushup.config.ts @@ -0,0 +1,45 @@ +import { type CoreConfig } from '@code-pushup/models'; + +export default { + upload: { + organization: 'code-pushup', + project: 'cli-ts', + apiKey: 'e2e-api-key', + server: 'https://e2e.com/api', + }, + categories: [ + { + slug: 'category-1', + title: 'Category 1', + refs: [ + { + type: 'audit', + plugin: 'node', + slug: 'node-version', + weight: 1, + }, + ], + }, + ], + plugins: [ + { + audits: [ + { + slug: 'node-version', + title: 'Node version', + description: 'prints node version to file', + docsUrl: 'https://nodejs.org/', + }, + ], + runner: { + command: 'node', + args: ['-v'], + outputFile: 'output.json', + }, + groups: [], + slug: 'node', + title: 'Node.js', + icon: 'javascript', + }, + ], +} satisfies CoreConfig; diff --git a/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig-fail.config.ts b/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig-fail.config.ts new file mode 100644 index 000000000..6ef0e8967 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig-fail.config.ts @@ -0,0 +1,10 @@ +// the point is to test runtime import which relies on alias defined in tsconfig.json "paths" +// non-type import from '@example/custom-plugin' wouldn't work without --tsconfig +// eslint-disable-next-line import/no-unresolved +import customPlugin from '@definitely-non-existent-package/custom-plugin'; + +const config = { + plugins: [customPlugin], +}; + +export default config; diff --git a/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig.config.ts b/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig.config.ts new file mode 100644 index 000000000..6ef0e8967 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/code-pushup.needs-tsconfig.config.ts @@ -0,0 +1,10 @@ +// the point is to test runtime import which relies on alias defined in tsconfig.json "paths" +// non-type import from '@example/custom-plugin' wouldn't work without --tsconfig +// eslint-disable-next-line import/no-unresolved +import customPlugin from '@definitely-non-existent-package/custom-plugin'; + +const config = { + plugins: [customPlugin], +}; + +export default config; diff --git a/packages/cli/mocks/fixtures/configs/custom-plugin.ts b/packages/cli/mocks/fixtures/configs/custom-plugin.ts new file mode 100644 index 000000000..6afe6bc80 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/custom-plugin.ts @@ -0,0 +1,24 @@ +const customPluginConfig = { + slug: 'good-feels', + title: 'Good feels', + icon: 'javascript', + audits: [ + { + slug: 'always-perfect', + title: 'Always perfect', + }, + ], + runner: () => [ + { + slug: 'always-perfect', + score: 1, + value: 100, + displayValue: '✅ Perfect! 👌', + }, + ], +}; + +export function customPlugin() { + return customPluginConfig; +} +export default customPluginConfig; diff --git a/packages/cli/mocks/fixtures/configs/tsconfig.alias.json b/packages/cli/mocks/fixtures/configs/tsconfig.alias.json new file mode 100644 index 000000000..f7f68cd18 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/tsconfig.alias.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@definitely-non-existent-package/custom-plugin": ["./custom-plugin.ts"] + } + } +} diff --git a/packages/cli/mocks/fixtures/configs/tsconfig.json b/packages/cli/mocks/fixtures/configs/tsconfig.json new file mode 100644 index 000000000..d43aec5e4 --- /dev/null +++ b/packages/cli/mocks/fixtures/configs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "paths": {} + } +} diff --git a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts index 9b37cceb4..12cb6387e 100644 --- a/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts +++ b/packages/cli/src/lib/implementation/core-config.middleware.int.test.ts @@ -3,17 +3,12 @@ import { fileURLToPath } from 'node:url'; import { describe, expect } from 'vitest'; import { coreConfigMiddleware } from './core-config.middleware.js'; -const configDirPath = path.join( +const localMocks = path.join( fileURLToPath(path.dirname(import.meta.url)), '..', '..', '..', - '..', - '..', - 'testing', - 'test-fixtures', - 'src', - 'lib', + 'mocks', 'fixtures', 'configs', ); @@ -29,7 +24,7 @@ describe('coreConfigMiddleware', () => { 'should load a valid .%s config', async extension => { const config = await coreConfigMiddleware({ - config: path.join(configDirPath, `code-pushup.config.${extension}`), + config: path.join(localMocks, `code-pushup.config.${extension}`), ...CLI_DEFAULTS, }); expect(config.config).toContain(`code-pushup.config.${extension}`); @@ -46,11 +41,8 @@ describe('coreConfigMiddleware', () => { it('should load config which relies on provided --tsconfig', async () => { await expect( coreConfigMiddleware({ - config: path.join( - configDirPath, - 'code-pushup.needs-tsconfig.config.ts', - ), - tsconfig: path.join(configDirPath, 'tsconfig.json'), + config: path.join(localMocks, 'code-pushup.needs-tsconfig.config.ts'), + tsconfig: path.join(localMocks, 'tsconfig.alias.json'), ...CLI_DEFAULTS, }), ).resolves.toBeTruthy(); @@ -60,11 +52,13 @@ describe('coreConfigMiddleware', () => { await expect( coreConfigMiddleware({ config: path.join( - configDirPath, - 'code-pushup.needs-tsconfig.config.ts', + localMocks, + 'code-pushup.needs-tsconfig-fail.config.ts', ), ...CLI_DEFAULTS, }), - ).rejects.toThrow("Cannot find package '@example/custom-plugin'"); + ).rejects.toThrow( + "Cannot find module '@definitely-non-existent-package/custom-plugin'", + ); }); }); diff --git a/packages/core/src/lib/implementation/read-rc-file.int.test.ts b/packages/core/src/lib/implementation/read-rc-file.int.test.ts index 006ea0a7a..1631d2948 100644 --- a/packages/core/src/lib/implementation/read-rc-file.int.test.ts +++ b/packages/core/src/lib/implementation/read-rc-file.int.test.ts @@ -55,7 +55,9 @@ describe('readRcByPath', () => { }); it('should throw if the path is empty', async () => { - await expect(readRcByPath('')).rejects.toThrow("File '' does not exist"); + await expect(readRcByPath('')).rejects.toThrow( + "Importing module failed. File '' does not exist", + ); }); it('should throw if the file does not exist', async () => { diff --git a/packages/core/src/lib/implementation/read-rc-file.ts b/packages/core/src/lib/implementation/read-rc-file.ts index 090ad2c0e..fb27a0733 100644 --- a/packages/core/src/lib/implementation/read-rc-file.ts +++ b/packages/core/src/lib/implementation/read-rc-file.ts @@ -27,7 +27,6 @@ export async function readRcByPath( const result = await importModule({ filepath: filePath, tsconfig, - format: 'esm', }); return { result, message: `Imported config from ${formattedTarget}` }; }, diff --git a/packages/core/src/lib/implementation/read-rc-file.unit.test.ts b/packages/core/src/lib/implementation/read-rc-file.unit.test.ts index 54387069b..5ec8b8861 100644 --- a/packages/core/src/lib/implementation/read-rc-file.unit.test.ts +++ b/packages/core/src/lib/implementation/read-rc-file.unit.test.ts @@ -4,32 +4,30 @@ import { CONFIG_FILE_NAME, type CoreConfig } from '@code-pushup/models'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { autoloadRc } from './read-rc-file.js'; -// mock bundleRequire inside importEsmModule used for fetching config -vi.mock('bundle-require', async () => { +vi.mock('@code-pushup/utils', async () => { const { CORE_CONFIG_MOCK }: Record = await vi.importActual('@code-pushup/test-fixtures'); + const actualUtils = await vi.importActual('@code-pushup/utils'); + return { - bundleRequire: vi + ...actualUtils, + importModule: vi .fn() .mockImplementation((options: { filepath: string }) => { const extension = options.filepath.split('.').at(-1); return { - mod: { - default: { - ...CORE_CONFIG_MOCK, - upload: { - ...CORE_CONFIG_MOCK?.upload, - project: extension, // returns loaded file extension to check format precedence - }, - }, + ...CORE_CONFIG_MOCK, + upload: { + ...CORE_CONFIG_MOCK?.upload, + project: extension, // returns loaded file extension to check format precedence }, }; }), }; }); -// Note: memfs files are only listed to satisfy a system check, value is used from bundle-require mock +// Note: memfs files are only listed to satisfy a system check, value is used from importModule mock describe('autoloadRc', () => { it('prioritise a .ts configuration file', async () => { vol.fromJSON( diff --git a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts index f2a5dd073..638c2d684 100644 --- a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts +++ b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts @@ -165,7 +165,6 @@ export async function getCoveragePathForVitest( const vitestConfig = await importModule({ filepath: config, - format: 'esm', }); const reportsDirectory = diff --git a/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts b/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts index 5bcaaa422..36504f567 100644 --- a/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts +++ b/packages/plugin-coverage/src/lib/nx/coverage-paths.unit.test.ts @@ -13,74 +13,77 @@ import { getCoveragePathsForTarget, } from './coverage-paths.js'; -vi.mock('bundle-require', () => ({ - bundleRequire: vi.fn().mockImplementation((options: { filepath: string }) => { - const VITEST_VALID: VitestCoverageConfig = { - test: { - coverage: { - reporter: ['lcov'], - reportsDirectory: path.join('coverage', 'cli'), - }, - }, - }; +vi.mock('@code-pushup/utils', async () => { + const actualUtils = await vi.importActual('@code-pushup/utils'); - const VITEST_NO_DIR: VitestCoverageConfig = { - test: { coverage: { reporter: ['lcov'] } }, - }; + return { + ...actualUtils, + importModule: vi + .fn() + .mockImplementation((options: { filepath: string }) => { + const VITEST_VALID: VitestCoverageConfig = { + test: { + coverage: { + reporter: ['lcov'], + reportsDirectory: path.join('coverage', 'cli'), + }, + }, + }; - const VITEST_NO_LCOV: VitestCoverageConfig = { - test: { - coverage: { - reporter: ['json'], - reportsDirectory: 'coverage', - }, - }, - }; + const VITEST_NO_DIR: VitestCoverageConfig = { + test: { coverage: { reporter: ['lcov'] } }, + }; - const JEST_VALID: JestCoverageConfig = { - coverageReporters: ['lcov'], - coverageDirectory: path.join('coverage', 'core'), - }; + const VITEST_NO_LCOV: VitestCoverageConfig = { + test: { + coverage: { + reporter: ['json'], + reportsDirectory: 'coverage', + }, + }, + }; - const JEST_NO_DIR: JestCoverageConfig = { - coverageReporters: ['lcov'], - }; + const JEST_VALID: JestCoverageConfig = { + coverageReporters: ['lcov'], + coverageDirectory: path.join('coverage', 'core'), + }; - const JEST_NO_LCOV: JestCoverageConfig = { - coverageReporters: ['json'], - coverageDirectory: 'coverage', - }; + const JEST_NO_DIR: JestCoverageConfig = { + coverageReporters: ['lcov'], + }; - const JEST_PRESET: JestCoverageConfig & { preset?: string } = { - preset: '../../jest.preset.ts', - coverageDirectory: 'coverage', - }; + const JEST_NO_LCOV: JestCoverageConfig = { + coverageReporters: ['json'], + coverageDirectory: 'coverage', + }; - const wrapReturnValue = ( - value: VitestCoverageConfig | JestCoverageConfig, - ) => ({ mod: { default: value } }); + const JEST_PRESET: JestCoverageConfig & { preset?: string } = { + preset: '../../jest.preset.ts', + coverageDirectory: 'coverage', + }; - const config = options.filepath.split('.')[0]; - switch (config) { - case 'vitest-valid': - return wrapReturnValue(VITEST_VALID); - case 'vitest-no-lcov': - return wrapReturnValue(VITEST_NO_LCOV); - case 'vitest-no-dir': - return wrapReturnValue(VITEST_NO_DIR); - case 'jest-valid': - return wrapReturnValue(JEST_VALID); - case 'jest-no-lcov': - return wrapReturnValue(JEST_NO_LCOV); - case 'jest-no-dir': - return wrapReturnValue(JEST_NO_DIR); - case 'jest-preset': - return wrapReturnValue(JEST_PRESET); - default: - return wrapReturnValue({}); - } - }), -})); + const config = options.filepath.split('.')[0]; + switch (config) { + case 'vitest-valid': + return VITEST_VALID; + case 'vitest-no-lcov': + return VITEST_NO_LCOV; + case 'vitest-no-dir': + return VITEST_NO_DIR; + case 'jest-valid': + return JEST_VALID; + case 'jest-no-lcov': + return JEST_NO_LCOV; + case 'jest-no-dir': + return JEST_NO_DIR; + case 'jest-preset': + return JEST_PRESET; + default: + return {}; + } + }), + }; +}); describe('getCoveragePathForTarget', () => { beforeEach(() => { diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.ts b/packages/plugin-lighthouse/src/lib/runner/utils.ts index a68ad368e..347c16bb4 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.ts @@ -144,7 +144,6 @@ export async function getConfig( message, result: await importModule({ filepath: configPath, - format: 'esm', }), }; } diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts index 458efe97d..a914745b2 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -26,25 +26,23 @@ import { withLocalTmpDir, } from './utils.js'; -// mock bundleRequire inside importEsmModule used for fetching config -vi.mock('bundle-require', async () => { +vi.mock('@code-pushup/utils', async () => { const { CORE_CONFIG_MOCK }: Record = await vi.importActual('@code-pushup/test-utils'); + const actualUtils = await vi.importActual('@code-pushup/utils'); + return { - bundleRequire: vi + ...actualUtils, + importModule: vi .fn() .mockImplementation((options: { filepath: string }) => { const project = options.filepath.split('.').at(-2); return { - mod: { - default: { - ...CORE_CONFIG_MOCK, - upload: { - ...CORE_CONFIG_MOCK?.upload, - project, // returns loaded file extension to check in test - }, - }, + ...CORE_CONFIG_MOCK, + upload: { + ...CORE_CONFIG_MOCK?.upload, + project, // returns loaded file extension to check in test }, }; }), diff --git a/packages/plugin-typescript/src/lib/runner/ts-runner.ts b/packages/plugin-typescript/src/lib/runner/ts-runner.ts index 6103b6e1e..d1541f126 100644 --- a/packages/plugin-typescript/src/lib/runner/ts-runner.ts +++ b/packages/plugin-typescript/src/lib/runner/ts-runner.ts @@ -3,8 +3,12 @@ import { createProgram, getPreEmitDiagnostics, } from 'typescript'; -import { logger, pluralizeToken, stringifyError } from '@code-pushup/utils'; -import { loadTargetConfig } from './utils.js'; +import { + loadTargetConfig, + logger, + pluralizeToken, + stringifyError, +} from '@code-pushup/utils'; export type DiagnosticsOptions = { tsconfig: string; diff --git a/packages/plugin-typescript/src/lib/runner/utils.ts b/packages/plugin-typescript/src/lib/runner/utils.ts index 23c3ee67b..48c68560c 100644 --- a/packages/plugin-typescript/src/lib/runner/utils.ts +++ b/packages/plugin-typescript/src/lib/runner/utils.ts @@ -3,9 +3,6 @@ import { type Diagnostic, DiagnosticCategory, flattenDiagnosticMessageText, - parseJsonConfigFileContent, - readConfigFile, - sys, } from 'typescript'; import type { Issue } from '@code-pushup/models'; import { truncateIssueMessage } from '@code-pushup/utils'; @@ -88,30 +85,3 @@ export function getIssueFromDiagnostic(diag: Diagnostic) { }, } satisfies Issue; } - -export function loadTargetConfig(tsConfigPath: string) { - const resolvedConfigPath = path.resolve(tsConfigPath); - const { config, error } = readConfigFile(resolvedConfigPath, sys.readFile); - - if (error) { - throw new Error( - `Error reading TypeScript config file at ${tsConfigPath}:\n${error.messageText}`, - ); - } - - const parsedConfig = parseJsonConfigFileContent( - config, - sys, - path.dirname(resolvedConfigPath), - {}, - resolvedConfigPath, - ); - - if (parsedConfig.fileNames.length === 0) { - throw new Error( - 'No files matched by the TypeScript configuration. Check your "include", "exclude" or "files" settings.', - ); - } - - return parsedConfig; -} diff --git a/packages/utils/mocks/fixtures/basic-setup/src/0-no-diagnostics/constants.ts b/packages/utils/mocks/fixtures/basic-setup/src/0-no-diagnostics/constants.ts new file mode 100644 index 000000000..9cbbe8809 --- /dev/null +++ b/packages/utils/mocks/fixtures/basic-setup/src/0-no-diagnostics/constants.ts @@ -0,0 +1 @@ +export const TEST = 'test'; diff --git a/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-base.json b/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-base.json new file mode 100644 index 000000000..d98091ca6 --- /dev/null +++ b/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-base.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "rootDir": "${configDir}", + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-extending.json b/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-extending.json new file mode 100644 index 000000000..f1b970c13 --- /dev/null +++ b/packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-extending.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.extends-base.json", + "compilerOptions": { + "verbatimModuleSyntax": true, + "module": "CommonJS" + }, + "exclude": ["src/*-errors/**/*.ts"] +} diff --git a/packages/utils/mocks/fixtures/basic-setup/tsconfig.init.json b/packages/utils/mocks/fixtures/basic-setup/tsconfig.init.json new file mode 100644 index 000000000..ba648354a --- /dev/null +++ b/packages/utils/mocks/fixtures/basic-setup/tsconfig.init.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/packages/utils/mocks/fixtures/tsconfig-setup/import-alias.ts b/packages/utils/mocks/fixtures/tsconfig-setup/import-alias.ts new file mode 100644 index 000000000..4488e1aa9 --- /dev/null +++ b/packages/utils/mocks/fixtures/tsconfig-setup/import-alias.ts @@ -0,0 +1,3 @@ +import { name } from '@utils'; + +export default `valid-ts-default-export-${name}`; diff --git a/packages/utils/mocks/fixtures/tsconfig-setup/tsconfig.json b/packages/utils/mocks/fixtures/tsconfig-setup/tsconfig.json new file mode 100644 index 000000000..d22493e7f --- /dev/null +++ b/packages/utils/mocks/fixtures/tsconfig-setup/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@utils/*": ["./utils.ts"] + } + }, + "include": ["*.ts"] +} diff --git a/packages/utils/mocks/fixtures/tsconfig-setup/utils.ts b/packages/utils/mocks/fixtures/tsconfig-setup/utils.ts new file mode 100644 index 000000000..a7bfb2e45 --- /dev/null +++ b/packages/utils/mocks/fixtures/tsconfig-setup/utils.ts @@ -0,0 +1 @@ +export const name = 'utils-export'; diff --git a/packages/utils/package.json b/packages/utils/package.json index aed4bca83..4af7e2940 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -30,14 +30,15 @@ "@code-pushup/models": "0.108.1", "ansis": "^3.3.0", "build-md": "^0.4.2", - "bundle-require": "^5.1.0", "esbuild": "^0.25.2", "ora": "^9.0.0", "semver": "^7.6.0", "simple-git": "^3.20.0", "string-width": "^8.1.0", "wrap-ansi": "^9.0.2", - "zod": "^4.2.1" + "zod": "^4.2.1", + "typescript": "5.7.3", + "jiti": "^2.6.1" }, "files": [ "src", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f019b8055..a06e834b5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,6 +32,7 @@ export { type ProcessObserver, type ProcessResult, } from './lib/execute-process.js'; +export { loadTargetConfig } from './lib/load-ts-config.js'; export { crawlFileSystem, createReportPath, @@ -41,7 +42,6 @@ export { filePathToCliArg, findLineNumberInText, findNearestFile, - importModule, pluginWorkDir, projectToFilename, readJsonFile, @@ -178,3 +178,4 @@ export type { Prettify, WithRequired, } from './lib/types.js'; +export * from './lib/import-module.js'; diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 5956dbbff..cbe72f547 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -1,9 +1,7 @@ -import { type Options, bundleRequire } from 'bundle-require'; import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { Format, PersistConfig } from '@code-pushup/models'; import { logger } from './logger.js'; -import { settlePromise } from './promises.js'; export async function readTextFile(filePath: string): Promise { const buffer = await readFile(filePath); @@ -52,23 +50,6 @@ export async function removeDirectoryIfExists(dir: string) { } } -export async function importModule(options: Options): Promise { - const resolvedStats = await settlePromise(stat(options.filepath)); - if (resolvedStats.status === 'rejected') { - throw new Error(`File '${options.filepath}' does not exist`); - } - if (!resolvedStats.value.isFile()) { - throw new Error(`Expected '${options.filepath}' to be a file`); - } - - const { mod } = await bundleRequire(options); - - if (typeof mod === 'object' && 'default' in mod) { - return mod.default as T; - } - return mod as T; -} - export function createReportPath({ outputDir, filename, diff --git a/packages/utils/src/lib/file-system.int.test.ts b/packages/utils/src/lib/import-module.int.test.ts similarity index 64% rename from packages/utils/src/lib/file-system.int.test.ts rename to packages/utils/src/lib/import-module.int.test.ts index b355e1bb1..bfbe51934 100644 --- a/packages/utils/src/lib/file-system.int.test.ts +++ b/packages/utils/src/lib/import-module.int.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { importModule } from './file-system.js'; +import { describe, expect, it, vi } from 'vitest'; +import { importModule } from './import-module.js'; describe('importModule', () => { const mockDir = path.join( @@ -45,10 +45,31 @@ describe('importModule', () => { ).resolves.toBe('valid-ts-default-export'); }); + it('imports module with default tsconfig when tsconfig undefined', async () => { + vi.clearAllMocks(); + await expect( + importModule({ + filepath: path.join(mockDir, 'valid-ts-default-export.ts'), + }), + ).resolves.toBe('valid-ts-default-export'); + }); + + it('imports module with custom tsconfig', async () => { + vi.clearAllMocks(); + await expect( + importModule({ + filepath: path.join(mockDir, 'tsconfig-setup', 'import-alias.ts'), + tsconfig: path.join(mockDir, 'tsconfig-setup', 'tsconfig.json'), + }), + ).resolves.toBe('valid-ts-default-export-utils-export'); + }); + it('should throw if the file does not exist', async () => { await expect( importModule({ filepath: 'path/to/non-existent-export.mjs' }), - ).rejects.toThrow("File 'path/to/non-existent-export.mjs' does not exist"); + ).rejects.toThrow( + `File '${path.resolve('path/to/non-existent-export.mjs')}' does not exist`, + ); }); it('should throw if path is a directory', async () => { @@ -57,11 +78,9 @@ describe('importModule', () => { ); }); - it('should throw if file is not valid JS', async () => { + it('should load valid JSON', async () => { await expect( importModule({ filepath: path.join(mockDir, 'invalid-js-file.json') }), - ).rejects.toThrow( - `${path.join(mockDir, 'invalid-js-file.json')} is not a valid JS file`, - ); + ).resolves.toStrictEqual({ key: 'value' }); }); }); diff --git a/packages/utils/src/lib/import-module.ts b/packages/utils/src/lib/import-module.ts new file mode 100644 index 000000000..326f63fa8 --- /dev/null +++ b/packages/utils/src/lib/import-module.ts @@ -0,0 +1,174 @@ +import { createJiti as createJitiSource } from 'jiti'; +import { stat } from 'node:fs/promises'; +import path from 'node:path'; +import type { CompilerOptions } from 'typescript'; +import { fileExists } from './file-system.js'; +import { loadTargetConfig } from './load-ts-config.js'; +import { settlePromise } from './promises.js'; + +// For unknown reason, we can't import `JitiOptions` directly in this repository +type JitiOptions = Exclude[1], undefined>; + +export type ImportModuleOptions = JitiOptions & { + filepath: string; + tsconfig?: string; +}; +export async function importModule( + options: ImportModuleOptions, +): Promise { + const { filepath, tsconfig, ...jitiOptions } = options; + + if (!filepath) { + throw new Error( + `Importing module failed. File '${filepath}' does not exist`, + ); + } + + const absoluteFilePath = path.resolve(process.cwd(), filepath); + const resolvedStats = await settlePromise(stat(absoluteFilePath)); + if (resolvedStats.status === 'rejected') { + throw new Error(`File '${absoluteFilePath}' does not exist`); + } + if (!resolvedStats.value.isFile()) { + throw new Error(`Expected '${filepath}' to be a file`); + } + + const jitiInstance = await createTsJiti(import.meta.url, { + ...jitiOptions, + tsconfigPath: tsconfig, + }); + + return (await jitiInstance.import(absoluteFilePath, { default: true })) as T; +} + +/** + * Converts TypeScript paths configuration to jiti alias format + * @param paths TypeScript paths object from compiler options + * @param baseUrl Base URL for resolving relative paths + * @returns Jiti alias object with absolute paths + */ +export function mapTsPathsToJitiAlias( + paths: Record, + baseUrl: string, +): Record { + return Object.entries(paths).reduce( + (aliases, [pathPattern, pathMappings]) => { + if (!Array.isArray(pathMappings) || pathMappings.length === 0) { + return aliases; + } + // Jiti does not support overloads (multiple mappings for the same path pattern) + if (pathMappings.length > 1) { + throw new Error( + `TypeScript path overloads are not supported by jiti. Path pattern '${pathPattern}' has ${pathMappings.length} mappings: ${pathMappings.join(', ')}. Jiti only supports a single alias mapping per pattern.`, + ); + } + const aliasKey = pathPattern.replace(/\/\*$/, ''); + const aliasValue = (pathMappings.at(0) as string).replace(/\/\*$/, ''); + return { + ...aliases, + [aliasKey]: path.isAbsolute(aliasValue) + ? aliasValue + : path.resolve(baseUrl, aliasValue), + }; + }, + {} satisfies Record, + ); +} + +/** + * Maps TypeScript JSX emit mode to Jiti JSX boolean option + * @param tsJsxMode TypeScript JsxEmit enum value (0-5) + * @returns true if JSX processing should be enabled, false otherwise + */ +export const mapTsJsxToJitiJsx = (tsJsxMode: number): boolean => + tsJsxMode !== 0; + +/** + * Possible TS to jiti options mapping + * | Jiti Option | Jiti Type | TS Option | TS Type | Description | + * |-------------------|-------------------------|-----------------------|--------------------------|-------------| + * | alias | Record | paths | Record | Module path aliases for module resolution. | + * | interopDefault | boolean | esModuleInterop | boolean | Enable default import interop. | + * | sourceMaps | boolean | sourceMap | boolean | Enable sourcemap generation. | + * | jsx | boolean | jsx | JsxEmit (0-5) | TS JsxEmit enum (0-5) => boolean JSX processing. | + */ +export type MappableJitiOptions = Partial< + Pick +>; +/** + * Parse TypeScript compiler options to mappable jiti options + * @param compilerOptions TypeScript compiler options + * @param tsconfigDir Directory of the tsconfig file (for resolving relative baseUrl) + * @returns Mappable jiti options + */ +export function parseTsConfigToJitiConfig( + compilerOptions: CompilerOptions, + tsconfigDir?: string, +): MappableJitiOptions { + const paths = compilerOptions.paths || {}; + const baseUrl = compilerOptions.baseUrl + ? path.isAbsolute(compilerOptions.baseUrl) + ? compilerOptions.baseUrl + : tsconfigDir + ? path.resolve(tsconfigDir, compilerOptions.baseUrl) + : path.resolve(process.cwd(), compilerOptions.baseUrl) + : tsconfigDir || process.cwd(); + + return { + ...(Object.keys(paths).length > 0 + ? { + alias: mapTsPathsToJitiAlias(paths, baseUrl), + } + : {}), + ...(compilerOptions.esModuleInterop == null + ? {} + : { interopDefault: compilerOptions.esModuleInterop }), + ...(compilerOptions.sourceMap == null + ? {} + : { sourceMaps: compilerOptions.sourceMap }), + ...(compilerOptions.jsx == null + ? {} + : { jsx: mapTsJsxToJitiJsx(compilerOptions.jsx) }), + }; +} + +/** + * Create a jiti instance with options derived from tsconfig. + * Used instead of direct jiti.createJiti to allow tsconfig integration. + * @param id + * @param options + * @param jiti + */ +export async function createTsJiti( + id: string, + options: JitiOptions & { tsconfigPath?: string } = {}, + createJiti: (typeof import('jiti'))['createJiti'] = createJitiSource, +) { + const { tsconfigPath, ...jitiOptions } = options; + + const fallbackTsconfigPath = path.resolve(process.cwd(), 'tsconfig.json'); + + const validPath: null | string = + tsconfigPath == null + ? (await fileExists(fallbackTsconfigPath)) + ? fallbackTsconfigPath + : null + : path.resolve(process.cwd(), tsconfigPath); + + const tsDerivedJitiOptions: MappableJitiOptions = validPath + ? await jitiOptionsFromTsConfig(validPath) + : {}; + + return createJiti(id, { ...jitiOptions, ...tsDerivedJitiOptions }); +} + +/** + * Read tsconfig file and parse options to jiti options + * @param tsconfigPath + */ +export async function jitiOptionsFromTsConfig( + tsconfigPath: string, +): Promise { + const { options } = loadTargetConfig(tsconfigPath); + return parseTsConfigToJitiConfig(options, path.dirname(tsconfigPath)); +} diff --git a/packages/utils/src/lib/import-module.unit.test.ts b/packages/utils/src/lib/import-module.unit.test.ts new file mode 100644 index 000000000..a5e8e8934 --- /dev/null +++ b/packages/utils/src/lib/import-module.unit.test.ts @@ -0,0 +1,109 @@ +import type { CompilerOptions } from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { + mapTsPathsToJitiAlias, + parseTsConfigToJitiConfig, +} from './import-module.js'; + +describe('mapTsPathsToJitiAlias', () => { + it('returns empty object when paths is empty', () => { + expect(mapTsPathsToJitiAlias({}, '/base')).toStrictEqual({}); + }); + + it('returns empty object when all path mappings are empty arrays', () => { + expect(mapTsPathsToJitiAlias({ '@/*': [] }, '/base')).toStrictEqual({}); + }); + + it('maps single path pattern without wildcards', () => { + expect(mapTsPathsToJitiAlias({ '@': ['src'] }, '/base')).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + }); + }); + + it('strips /* from path pattern and mapping', () => { + expect(mapTsPathsToJitiAlias({ '@/*': ['src/*'] }, '/base')).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + }); + }); + + it('resolves relative path mappings to absolute', () => { + expect(mapTsPathsToJitiAlias({ '@/*': ['src/*'] }, '/app')).toStrictEqual({ + '@': expect.pathToEndWith('app/src'), + }); + }); + + it('keeps absolute path mappings as-is', () => { + expect( + mapTsPathsToJitiAlias({ '@/*': ['/absolute/path/*'] }, '/base'), + ).toStrictEqual({ '@': '/absolute/path' }); + }); + + it('throws error when path overloads exist (multiple mappings)', () => { + expect(() => + mapTsPathsToJitiAlias({ '@/*': ['first/*', 'second/*'] }, '/base'), + ).toThrow( + "TypeScript path overloads are not supported by jiti. Path pattern '@/*' has 2 mappings: first/*, second/*. Jiti only supports a single alias mapping per pattern.", + ); + }); + + it('maps multiple path patterns', () => { + expect( + mapTsPathsToJitiAlias( + { + '@/*': ['src/*'], + '~/*': ['lib/*'], + }, + '/base', + ), + ).toStrictEqual({ + '@': expect.pathToEndWith('base/src'), + '~': expect.pathToEndWith('base/lib'), + }); + }); + + it('filters out invalid mappings and keeps valid ones', () => { + expect( + mapTsPathsToJitiAlias( + { + 'invalid/*': [], + '@/*': ['src/*'], + 'also-invalid': [], + }, + '/base', + ), + ).toStrictEqual({ + '@': expect.pathToEndWith('src'), + }); + }); +}); + +describe('parseTsConfigToJitiConfig', () => { + it('returns empty object when compiler options are empty', () => { + expect(parseTsConfigToJitiConfig({})).toStrictEqual({}); + }); + + it('includes all options jiti can use', () => { + const compilerOptions: CompilerOptions = { + paths: { + '@app/*': ['src/*'], + '@lib/*': ['lib/*'], + }, + esModuleInterop: true, + sourceMap: true, + jsx: 2, // JsxEmit.React + include: ['**/*.ts'], + + baseUrl: '/base', + }; + + expect(parseTsConfigToJitiConfig(compilerOptions)).toStrictEqual({ + alias: { + '@app': expect.pathToEndWith('src'), + '@lib': expect.pathToEndWith('lib'), + }, + interopDefault: true, + sourceMaps: true, + jsx: true, + }); + }); +}); diff --git a/packages/plugin-typescript/src/lib/runner/utils.int.test.ts b/packages/utils/src/lib/load-ts-config.int.test.ts similarity index 90% rename from packages/plugin-typescript/src/lib/runner/utils.int.test.ts rename to packages/utils/src/lib/load-ts-config.int.test.ts index c202d59be..fd4bc5842 100644 --- a/packages/plugin-typescript/src/lib/runner/utils.int.test.ts +++ b/packages/utils/src/lib/load-ts-config.int.test.ts @@ -1,7 +1,7 @@ import * as tsModule from 'typescript'; import { describe, expect, vi } from 'vitest'; import { osAgnosticPath } from '@code-pushup/test-utils'; -import { loadTargetConfig } from './utils.js'; +import { loadTargetConfig } from './load-ts-config.js'; describe('loadTargetConfig', () => { const readConfigFileSpy = vi.spyOn(tsModule, 'readConfigFile'); @@ -14,7 +14,7 @@ describe('loadTargetConfig', () => { expect( loadTargetConfig( osAgnosticPath( - 'packages/plugin-typescript/mocks/fixtures/basic-setup/tsconfig.init.json', + 'packages/utils/mocks/fixtures/basic-setup/tsconfig.init.json', ), ), ).toStrictEqual( @@ -42,7 +42,7 @@ describe('loadTargetConfig', () => { it('should return the parsed content of a tsconfig file that extends another config', () => { expect( loadTargetConfig( - 'packages/plugin-typescript/mocks/fixtures/basic-setup/tsconfig.extends-extending.json', + 'packages/utils/mocks/fixtures/basic-setup/tsconfig.extends-extending.json', ), ).toStrictEqual( expect.objectContaining({ diff --git a/packages/utils/src/lib/load-ts-config.ts b/packages/utils/src/lib/load-ts-config.ts new file mode 100644 index 000000000..010732ebf --- /dev/null +++ b/packages/utils/src/lib/load-ts-config.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; +import { parseJsonConfigFileContent, readConfigFile, sys } from 'typescript'; + +export function loadTargetConfig(tsConfigPath: string) { + const resolvedConfigPath = path.resolve(tsConfigPath); + const { config, error } = readConfigFile(resolvedConfigPath, sys.readFile); + + if (error) { + throw new Error( + `Error reading TypeScript config file at ${tsConfigPath}:\n${error.messageText}`, + ); + } + + const parsedConfig = parseJsonConfigFileContent( + config, + sys, + path.dirname(resolvedConfigPath), + {}, + resolvedConfigPath, + ); + + if (parsedConfig.fileNames.length === 0) { + throw new Error( + 'No files matched by the TypeScript configuration. Check your "include", "exclude" or "files" settings.', + ); + } + + return parsedConfig; +} diff --git a/testing/test-fixtures/src/lib/fixtures/configs/code-pushup.needs-tsconfig.config.ts b/testing/test-fixtures/src/lib/fixtures/configs/code-pushup.needs-tsconfig.config.ts index 44b4d0a2d..6ef0e8967 100644 --- a/testing/test-fixtures/src/lib/fixtures/configs/code-pushup.needs-tsconfig.config.ts +++ b/testing/test-fixtures/src/lib/fixtures/configs/code-pushup.needs-tsconfig.config.ts @@ -1,7 +1,7 @@ // the point is to test runtime import which relies on alias defined in tsconfig.json "paths" // non-type import from '@example/custom-plugin' wouldn't work without --tsconfig // eslint-disable-next-line import/no-unresolved -import customPlugin from '@example/custom-plugin'; +import customPlugin from '@definitely-non-existent-package/custom-plugin'; const config = { plugins: [customPlugin], diff --git a/testing/test-fixtures/src/lib/fixtures/configs/tsconfig.json b/testing/test-fixtures/src/lib/fixtures/configs/tsconfig.json index 42976b47b..f7f68cd18 100644 --- a/testing/test-fixtures/src/lib/fixtures/configs/tsconfig.json +++ b/testing/test-fixtures/src/lib/fixtures/configs/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "paths": { - "@example/custom-plugin": ["./custom-plugin.ts"] + "@definitely-non-existent-package/custom-plugin": ["./custom-plugin.ts"] } } }