diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index ea8d58ae6..38d1ffcff 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -1,26 +1,6 @@ /* eslint-disable @nx/enforce-module-boundaries */ import { createProjectGraphAsync } from '@nx/devkit'; import type { CoreConfig, PluginUrls } from './packages/models/src/index.js'; -import axePlugin, { - type AxePluginOptions, - axeGroupRefs, -} from './packages/plugin-axe/src/index.js'; -import coveragePlugin, { - type CoveragePluginConfig, - getNxCoveragePaths, -} from './packages/plugin-coverage/src/index.js'; -import eslintPlugin, { - eslintConfigFromAllNxProjects, -} from './packages/plugin-eslint/src/index.js'; -import jsPackagesPlugin from './packages/plugin-js-packages/src/index.js'; -import jsDocsPlugin from './packages/plugin-jsdocs/src/index.js'; -import { - lighthouseGroupRefs, - lighthousePlugin, -} from './packages/plugin-lighthouse/src/index.js'; -import typescriptPlugin, { - getCategories, -} from './packages/plugin-typescript/src/index.js'; export function configureUpload(projectName: string = 'workspace'): CoreConfig { return { @@ -39,6 +19,10 @@ export function configureUpload(projectName: string = 'workspace'): CoreConfig { export async function configureEslintPlugin( projectName?: string, ): Promise { + const { default: eslintPlugin, eslintConfigFromAllNxProjects } = await import( + './packages/plugin-eslint/src/index.js' + ); + return { plugins: [ projectName @@ -81,8 +65,12 @@ export async function configureEslintPlugin( export async function configureCoveragePlugin( projectName?: string, ): Promise { + const { default: coveragePlugin, getNxCoveragePaths } = await import( + './packages/plugin-coverage/src/index.js' + ); + const targets = ['unit-test', 'int-test']; - const config: CoveragePluginConfig = projectName + const config = projectName ? // We do not need to run a coverageToolCommand. This is handled over the Nx task graph. { reports: Object.keys( @@ -117,6 +105,10 @@ export async function configureCoveragePlugin( } export async function configureJsPackagesPlugin(): Promise { + const { default: jsPackagesPlugin } = await import( + './packages/plugin-js-packages/src/index.js' + ); + return { plugins: [await jsPackagesPlugin()], categories: [ @@ -150,7 +142,13 @@ export async function configureJsPackagesPlugin(): Promise { }; } -export function configureTypescriptPlugin(projectName?: string): CoreConfig { +export async function configureTypescriptPlugin( + projectName?: string, +): Promise { + const { default: typescriptPlugin, getCategories } = await import( + './packages/plugin-typescript/src/index.js' + ); + const tsconfig = projectName ? `packages/${projectName}/tsconfig.lib.json` : 'tsconfig.code-pushup.json'; @@ -160,7 +158,13 @@ export function configureTypescriptPlugin(projectName?: string): CoreConfig { }; } -export function configureJsDocsPlugin(projectName?: string): CoreConfig { +export async function configureJsDocsPlugin( + projectName?: string, +): Promise { + const { default: jsDocsPlugin } = await import( + './packages/plugin-jsdocs/src/index.js' + ); + const patterns: string[] = [ `packages/${projectName ?? '*'}/src/**/*.ts`, `!**/node_modules`, @@ -192,6 +196,10 @@ export function configureJsDocsPlugin(projectName?: string): CoreConfig { export async function configureLighthousePlugin( urls: PluginUrls, ): Promise { + const { lighthousePlugin, lighthouseGroupRefs } = await import( + './packages/plugin-lighthouse/src/index.js' + ); + const lhPlugin = await lighthousePlugin(urls); return { plugins: [lhPlugin], @@ -220,10 +228,14 @@ export async function configureLighthousePlugin( }; } -export function configureAxePlugin( +export async function configureAxePlugin( urls: PluginUrls, options?: AxePluginOptions, -): CoreConfig { +): Promise { + const { default: axePlugin, axeGroupRefs } = await import( + './packages/plugin-axe/src/index.js' + ); + const axe = axePlugin(urls, options); return { plugins: [axe], diff --git a/e2e/ci-e2e/eslint.config.js b/e2e/ci-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/ci-e2e/eslint.config.js +++ b/e2e/ci-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/cli-e2e/eslint.config.js b/e2e/cli-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/cli-e2e/eslint.config.js +++ b/e2e/cli-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); 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/e2e/create-cli-e2e/eslint.config.js b/e2e/create-cli-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/create-cli-e2e/eslint.config.js +++ b/e2e/create-cli-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/nx-plugin-e2e/eslint.config.js b/e2e/nx-plugin-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/nx-plugin-e2e/eslint.config.js +++ b/e2e/nx-plugin-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-axe-e2e/eslint.config.js b/e2e/plugin-axe-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-axe-e2e/eslint.config.js +++ b/e2e/plugin-axe-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-coverage-e2e/eslint.config.js b/e2e/plugin-coverage-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-coverage-e2e/eslint.config.js +++ b/e2e/plugin-coverage-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-eslint-e2e/eslint.config.js b/e2e/plugin-eslint-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-eslint-e2e/eslint.config.js +++ b/e2e/plugin-eslint-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-js-packages-e2e/eslint.config.js b/e2e/plugin-js-packages-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-js-packages-e2e/eslint.config.js +++ b/e2e/plugin-js-packages-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-jsdocs-e2e/eslint.config.js b/e2e/plugin-jsdocs-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-jsdocs-e2e/eslint.config.js +++ b/e2e/plugin-jsdocs-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-lighthouse-e2e/eslint.config.js b/e2e/plugin-lighthouse-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-lighthouse-e2e/eslint.config.js +++ b/e2e/plugin-lighthouse-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/e2e/plugin-typescript-e2e/eslint.config.js b/e2e/plugin-typescript-e2e/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/e2e/plugin-typescript-e2e/eslint.config.js +++ b/e2e/plugin-typescript-e2e/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/eslint.config.js b/eslint.config.js index 7397d95d0..ad54c05d0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,176 +5,183 @@ import fs from 'node:fs'; import tseslint from 'typescript-eslint'; import node from '@code-pushup/eslint-config/node.js'; import typescript from '@code-pushup/eslint-config/typescript.js'; -import vitest from '@code-pushup/eslint-config/vitest.js'; -export default tseslint.config( - ...typescript, - ...node, - ...vitest, - { - settings: { - 'import/resolver': { typescript: { project: 'tsconfig.base.json' } }, +export default async () => { + const { default: vitest } = await import( + '@code-pushup/eslint-config/vitest.js' + ); + + const vitestConfig = typeof vitest === 'function' ? await vitest() : vitest; + + return tseslint.config( + ...typescript, + ...node, + ...vitestConfig, + { + settings: { + 'import/resolver': { typescript: { project: 'tsconfig.base.json' } }, + }, }, - }, - { plugins: { '@nx': nxEslintPlugin } }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], - rules: { - '@nx/enforce-module-boundaries': [ - 'error', - { - enforceBuildableLibDependency: true, - allow: [ - String.raw`^.*/eslint(\.base)?\.config\.[cm]?js$`, - String.raw`^.*/code-pushup\.(config|preset)(\.m?[jt]s)?$`, - '^[./]+/tools/.*$', - String.raw`^[./]+/(testing/)?test-setup-config/src/index\.js$`, - ], - depConstraints: [ - { - sourceTag: 'scope:shared', - onlyDependOnLibsWithTags: ['scope:shared'], - }, - { - sourceTag: 'scope:core', - onlyDependOnLibsWithTags: ['scope:core', 'scope:shared'], - }, - { - sourceTag: 'scope:plugin', - onlyDependOnLibsWithTags: ['scope:shared'], - }, - { - sourceTag: 'scope:tooling', - onlyDependOnLibsWithTags: ['scope:tooling', 'scope:shared'], - }, - { - sourceTag: 'type:e2e', - onlyDependOnLibsWithTags: [ - 'type:app', - 'type:feature', - 'type:util', - 'type:testing', - ], - }, - { - sourceTag: 'type:app', - onlyDependOnLibsWithTags: [ - 'type:feature', - 'type:util', - 'type:testing', - ], - }, - { - sourceTag: 'type:feature', - onlyDependOnLibsWithTags: [ - 'type:feature', - 'type:util', - 'type:testing', - ], - }, - { - sourceTag: 'type:util', - onlyDependOnLibsWithTags: ['type:util', 'type:testing'], - }, - { - sourceTag: 'type:testing', - onlyDependOnLibsWithTags: ['type:util', 'type:testing'], - }, - ], - }, - ], + { plugins: { '@nx': nxEslintPlugin } }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [ + String.raw`^.*/eslint(\.base)?\.config\.[cm]?js$`, + String.raw`^.*/code-pushup\.(config|preset)(\.m?[jt]s)?$`, + '^[./]+/tools/.*$', + String.raw`^[./]+/(testing/)?test-setup-config/src/index\.js$`, + ], + depConstraints: [ + { + sourceTag: 'scope:shared', + onlyDependOnLibsWithTags: ['scope:shared'], + }, + { + sourceTag: 'scope:core', + onlyDependOnLibsWithTags: ['scope:core', 'scope:shared'], + }, + { + sourceTag: 'scope:plugin', + onlyDependOnLibsWithTags: ['scope:shared'], + }, + { + sourceTag: 'scope:tooling', + onlyDependOnLibsWithTags: ['scope:tooling', 'scope:shared'], + }, + { + sourceTag: 'type:e2e', + onlyDependOnLibsWithTags: [ + 'type:app', + 'type:feature', + 'type:util', + 'type:testing', + ], + }, + { + sourceTag: 'type:app', + onlyDependOnLibsWithTags: [ + 'type:feature', + 'type:util', + 'type:testing', + ], + }, + { + sourceTag: 'type:feature', + onlyDependOnLibsWithTags: [ + 'type:feature', + 'type:util', + 'type:testing', + ], + }, + { + sourceTag: 'type:util', + onlyDependOnLibsWithTags: ['type:util', 'type:testing'], + }, + { + sourceTag: 'type:testing', + onlyDependOnLibsWithTags: ['type:util', 'type:testing'], + }, + ], + }, + ], + }, }, - }, - { - files: ['**/*.test.ts', '**/*.spec.ts'], - plugins: { 'jest-extended': jestExtendedPlugin }, - rules: { - 'vitest/consistent-test-filename': [ - 'warn', - { - pattern: String.raw`.*\.(bench|type|unit|int|e2e)\.test\.[tj]sx?$`, - }, - ], - 'jest-extended/prefer-to-be-array': 'warn', - 'jest-extended/prefer-to-be-false': 'warn', - 'jest-extended/prefer-to-be-object': 'warn', - 'jest-extended/prefer-to-be-true': 'warn', - 'jest-extended/prefer-to-have-been-called-once': 'warn', + { + files: ['**/*.test.ts', '**/*.spec.ts'], + plugins: { 'jest-extended': jestExtendedPlugin }, + rules: { + 'vitest/consistent-test-filename': [ + 'warn', + { + pattern: String.raw`.*\.(bench|type|unit|int|e2e)\.test\.[tj]sx?$`, + }, + ], + 'jest-extended/prefer-to-be-array': 'warn', + 'jest-extended/prefer-to-be-false': 'warn', + 'jest-extended/prefer-to-be-object': 'warn', + 'jest-extended/prefer-to-be-true': 'warn', + 'jest-extended/prefer-to-have-been-called-once': 'warn', + }, }, - }, - { - files: ['**/*.type.test.ts'], - rules: { - 'vitest/expect-expect': 'off', + { + files: ['**/*.type.test.ts'], + rules: { + 'vitest/expect-expect': 'off', + }, }, - }, - { - files: ['**/*.json'], - languageOptions: { parser: jsoncParser }, - }, - { - files: ['**/*.ts', '**/*.js'], - rules: { - 'n/file-extension-in-import': ['error', 'always'], - 'unicorn/number-literal-case': 'off', + { + files: ['**/*.json'], + languageOptions: { parser: jsoncParser }, }, - }, - { - files: ['**/perf/**/*.ts'], - rules: { - '@typescript-eslint/no-magic-numbers': 'off', - 'sonarjs/no-duplicate-string': 'off', + { + files: ['**/*.ts', '**/*.js'], + rules: { + 'n/file-extension-in-import': ['error', 'always'], + 'unicorn/number-literal-case': 'off', + }, }, - }, - { - // tests need only be compatible with local Node version - // publishable packages should pick up version range from "engines" in their package.json - files: ['e2e/**/*.ts', 'testing/**/*.ts', '**/*.test.ts'], - settings: { - node: { - version: fs.readFileSync('.node-version', 'utf8'), + { + files: ['**/perf/**/*.ts'], + rules: { + '@typescript-eslint/no-magic-numbers': 'off', + 'sonarjs/no-duplicate-string': 'off', }, }, - }, - { - ignores: [ - '**/*.mock.*', - '**/code-pushup.config.ts', - '**/mocks/fixtures/**', - '**/__snapshots__/**', - '**/dist', - '**/*.md', - ], - }, - { - files: ['packages/**/*.ts'], - rules: { - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: '@nx/devkit', - importNames: ['logger'], - message: 'Please use logger from @code-pushup/utils instead.', - }, - ], + { + // tests need only be compatible with local Node version + // publishable packages should pick up version range from "engines" in their package.json + files: ['e2e/**/*.ts', 'testing/**/*.ts', '**/*.test.ts'], + settings: { + node: { + version: fs.readFileSync('.node-version', 'utf8'), }, + }, + }, + { + ignores: [ + '**/*.mock.*', + '**/code-pushup.config.ts', + '**/mocks/fixtures/**', + '**/__snapshots__/**', + '**/dist', + '**/*.md', ], }, - }, - { - // in bin files, imports with side effects are allowed - files: ['**/bin/**/*.ts', '**/bin/**/*.js'], - rules: { - 'import/no-unassigned-import': 'off', + { + files: ['packages/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@nx/devkit', + importNames: ['logger'], + message: 'Please use logger from @code-pushup/utils instead.', + }, + ], + }, + ], + }, + }, + { + // in bin files, imports with side effects are allowed + files: ['**/bin/**/*.ts', '**/bin/**/*.js'], + rules: { + 'import/no-unassigned-import': 'off', + }, }, - }, - { - // in *nx-plugin.ts files path imports cant be default export style (@TODO understand relation to swc) - files: ['**/*nx-plugin.ts'], - rules: { - 'unicorn/import-style': 'off', + { + // in *nx-plugin.ts files path imports cant be default export style (@TODO understand relation to swc) + files: ['**/*nx-plugin.ts'], + rules: { + 'unicorn/import-style': 'off', + }, }, - }, -); + ); +}; diff --git a/examples/plugins/eslint.config.js b/examples/plugins/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/examples/plugins/eslint.config.js +++ b/examples/plugins/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/nx.json b/nx.json index 7e7265dfe..3decd0361 100644 --- a/nx.json +++ b/nx.json @@ -38,7 +38,6 @@ ], "code-pushup-inputs": [ "{workspaceRoot}/code-pushup.preset.ts", - { "env": "NODE_OPTIONS" }, { "env": "TSX_TSCONFIG_PATH" } ], "sharedGlobals": [{ "runtime": "node -v" }, { "runtime": "npm -v" }] diff --git a/package-lock.json b/package-lock.json index b9c0986ff..c5203f1c3 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.1.1", "@push-based/nx-verdaccio": "0.0.7", "@swc-node/register": "1.9.2", "@swc/cli": "0.6.0", @@ -64,6 +65,7 @@ "@types/benchmark": "^2.1.5", "@types/debug": "^4.1.12", "@types/eslint": "^8.44.2", + "@types/jsdom": "^27.0.0", "@types/node": "^22.13.4", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", @@ -92,7 +94,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 +4619,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 +6328,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@push-based/jiti-tsc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@push-based/jiti-tsc/-/jiti-tsc-0.1.1.tgz", + "integrity": "sha512-2pi6jyxwr0DMFbsmgosfTUh1AEDWScyw97/KnTAfrKf9q33abtoqRHqF69+MJYex9O2Vlq8gye0XTkEy6dCPFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "^2.4.2", + "tslib": "^2.6.2", + "typescript": "5.7.3" + }, + "bin": { + "jiti-tsc": "src/cli.js" + }, + "engines": { + "node": ">=20.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", @@ -9278,6 +9308,18 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -9380,6 +9422,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -22220,9 +22269,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..30d51a5ea 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.1.1", "@push-based/nx-verdaccio": "0.0.7", "@swc-node/register": "1.9.2", "@swc/cli": "0.6.0", @@ -74,6 +75,7 @@ "@types/benchmark": "^2.1.5", "@types/debug": "^4.1.12", "@types/eslint": "^8.44.2", + "@types/jsdom": "^27.0.0", "@types/node": "^22.13.4", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", @@ -102,7 +104,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/ci/eslint.config.js b/packages/ci/eslint.config.js index 13888c2a8..8af358cd9 100644 --- a/packages/ci/eslint.config.js +++ b/packages/ci/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/cli/eslint.config.js +++ b/packages/cli/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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/eslint.config.js b/packages/core/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/core/eslint.config.js +++ b/packages/core/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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/create-cli/eslint.config.js b/packages/create-cli/eslint.config.js index 22fda2e40..72617ff9a 100644 --- a/packages/create-cli/eslint.config.js +++ b/packages/create-cli/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/packages/models/code-pushup.config.ts b/packages/models/code-pushup.config.ts index f34ecdcde..ca7f05db6 100644 --- a/packages/models/code-pushup.config.ts +++ b/packages/models/code-pushup.config.ts @@ -15,7 +15,7 @@ const config: CoreConfig = [ await configureCoveragePlugin(projectName), // FIXME: Can't create TS program in getDiagnostics. Cannot find module './packages/models/transformers/dist' // configureTypescriptPlugin(projectName), - configureJsDocsPlugin(projectName), + await configureJsDocsPlugin(projectName), ].reduce( (acc, { plugins, categories }) => ({ ...acc, diff --git a/packages/models/eslint.config.js b/packages/models/eslint.config.js index 48fd0b2be..602a07662 100644 --- a/packages/models/eslint.config.js +++ b/packages/models/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['/**/*.ts'], languageOptions: { diff --git a/packages/nx-plugin/eslint.config.js b/packages/nx-plugin/eslint.config.js index f0ea93505..73bd854a1 100644 --- a/packages/nx-plugin/eslint.config.js +++ b/packages/nx-plugin/eslint.config.js @@ -1,53 +1,56 @@ const tseslint = require('typescript-eslint'); const baseConfig = require('../../eslint.config.js').default; -module.exports = tseslint.config( - ...baseConfig, - { - files: ['**/*.ts'], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: __dirname, +// eslint-disable-next-line unicorn/prefer-top-level-await, arrow-body-style +module.exports = (async () => { + return tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, }, }, - }, - { - files: ['**/*.ts'], - rules: { - // Nx plugins don't yet support ESM: https://github.com/nrwl/nx/issues/15682 - 'unicorn/prefer-module': 'off', - // used instead of verbatimModuleSyntax tsconfig flag (requires ESM) - '@typescript-eslint/consistent-type-imports': [ - 'warn', - { - fixStyle: 'inline-type-imports', - disallowTypeAnnotations: false, - }, - ], - '@typescript-eslint/consistent-type-exports': [ - 'warn', - { fixMixedExportsWithInlineTypeSpecifier: true }, - ], - // `import path from 'node:path'` incompatible with CJS runtime, prefer `import * as path from 'node:path'` - 'unicorn/import-style': [ - 'warn', - { styles: { 'node:path': { namespace: true } } }, - ], - // `import { logger } from '@nx/devkit' is OK here - 'no-restricted-imports': 'off', + { + files: ['**/*.ts'], + rules: { + // Nx plugins don't yet support ESM: https://github.com/nrwl/nx/issues/15682 + 'unicorn/prefer-module': 'off', + // used instead of verbatimModuleSyntax tsconfig flag (requires ESM) + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { + fixStyle: 'inline-type-imports', + disallowTypeAnnotations: false, + }, + ], + '@typescript-eslint/consistent-type-exports': [ + 'warn', + { fixMixedExportsWithInlineTypeSpecifier: true }, + ], + // `import path from 'node:path'` incompatible with CJS runtime, prefer `import * as path from 'node:path'` + 'unicorn/import-style': [ + 'warn', + { styles: { 'node:path': { namespace: true } } }, + ], + // `import { logger } from '@nx/devkit' is OK here + 'no-restricted-imports': 'off', + }, }, - }, - { - files: ['**/*.json'], - rules: { - '@nx/dependency-checks': 'error', + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': 'error', + }, }, - }, - { - files: ['**/package.json', '**/generators.json'], - rules: { - '@nx/nx-plugin-checks': 'error', + { + files: ['**/package.json', '**/generators.json'], + rules: { + '@nx/nx-plugin-checks': 'error', + }, }, - }, -); + ); +})(); diff --git a/packages/plugin-axe/eslint.config.js b/packages/plugin-axe/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/packages/plugin-axe/eslint.config.js +++ b/packages/plugin-axe/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/packages/plugin-axe/package.json b/packages/plugin-axe/package.json index daaf04641..3e18fa1b8 100644 --- a/packages/plugin-axe/package.json +++ b/packages/plugin-axe/package.json @@ -45,6 +45,7 @@ "@code-pushup/models": "0.108.1", "@code-pushup/utils": "0.108.1", "axe-core": "^4.11.0", + "jsdom": "^25.0.0", "playwright-core": "^1.56.1", "zod": "^4.2.1" }, diff --git a/packages/plugin-axe/src/_index.ts b/packages/plugin-axe/src/_index.ts new file mode 100644 index 000000000..655266b33 --- /dev/null +++ b/packages/plugin-axe/src/_index.ts @@ -0,0 +1,15 @@ +export { axePlugin } from './lib/axe-plugin.js'; + +export type { AxePluginOptions, AxePreset } from './lib/config.js'; +export type { AxeGroupSlug } from './lib/groups.js'; + +export { + axeAuditRef, + axeAuditRefs, + axeGroupRef, + axeGroupRefs, +} from './lib/utils.js'; +export { axeCategories } from './lib/categories.js'; + +// Utility for working with DOM-dependent libraries +export { withDom } from './lib/safe-axe-import.js'; diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts index fc6a6c5b6..7d2c52825 100644 --- a/packages/plugin-axe/src/index.ts +++ b/packages/plugin-axe/src/index.ts @@ -2,13 +2,4 @@ import { axePlugin } from './lib/axe-plugin.js'; export default axePlugin; -export type { AxePluginOptions, AxePreset } from './lib/config.js'; -export type { AxeGroupSlug } from './lib/groups.js'; - -export { - axeAuditRef, - axeAuditRefs, - axeGroupRef, - axeGroupRefs, -} from './lib/utils.js'; -export { axeCategories } from './lib/categories.js'; +export * from './_index.js'; diff --git a/packages/plugin-axe/src/lib/axe-plugin.ts b/packages/plugin-axe/src/lib/axe-plugin.ts index 9d867a6ae..6092b6d87 100644 --- a/packages/plugin-axe/src/lib/axe-plugin.ts +++ b/packages/plugin-axe/src/lib/axe-plugin.ts @@ -18,10 +18,10 @@ import { createRunnerFunction } from './runner/runner.js'; * @param options - {@link AxePluginOptions} Plugin options * @returns Plugin configuration */ -export function axePlugin( +export async function axePlugin( urls: PluginUrls, options: AxePluginOptions = {}, -): PluginConfig { +): Promise { const { preset, scoreTargets, timeout, setupScript } = validate( axePluginOptionsSchema, options, @@ -29,7 +29,7 @@ export function axePlugin( const { urls: normalizedUrls, context } = normalizeUrlInput(urls); - const { audits, groups, ruleIds } = processAuditsAndGroups( + const { audits, groups, ruleIds } = await processAuditsAndGroups( normalizedUrls, preset, ); diff --git a/packages/plugin-axe/src/lib/groups.int.test.ts b/packages/plugin-axe/src/lib/groups.int.test.ts index f344da177..817691ae3 100644 --- a/packages/plugin-axe/src/lib/groups.int.test.ts +++ b/packages/plugin-axe/src/lib/groups.int.test.ts @@ -1,30 +1,42 @@ -import axe from 'axe-core'; import { describe, expect, it } from 'vitest'; import { axeCategoryGroupSlugSchema, axeWcagTagSchema } from './groups.js'; +import { importAxeCore } from './safe-axe-import.js'; describe('axeCategoryGroupSlugSchema', () => { - const axeCategoryTags = axe - .getRules() - .flatMap(rule => rule.tags) - .filter(tag => tag.startsWith('cat.')); + it('should not have categories removed by axe-core', async () => { + const axe = await importAxeCore(); + const axeCategoryTags = axe + .getRules() + .flatMap(rule => rule.tags) + .filter(tag => tag.startsWith('cat.')); - const ourCategoryTags = axeCategoryGroupSlugSchema.options.map( - slug => `cat.${slug}`, - ); + const ourCategoryTags = axeCategoryGroupSlugSchema.options.map( + slug => `cat.${slug}`, + ); - it('should not have categories removed by axe-core', () => { expect(axeCategoryTags).toIncludeAllMembers(ourCategoryTags); }); - it('should not be missing categories added by axe-core', () => { + it('should not be missing categories added by axe-core', async () => { + const axe = await importAxeCore(); + const axeCategoryTags = axe + .getRules() + .flatMap(rule => rule.tags) + .filter(tag => tag.startsWith('cat.')); + + const ourCategoryTags = axeCategoryGroupSlugSchema.options.map( + slug => `cat.${slug}`, + ); + expect(ourCategoryTags).toIncludeAllMembers(axeCategoryTags); }); }); describe('axeWcagTagSchema', () => { - const axeTags = axe.getRules().flatMap(rule => rule.tags); + it('should not have WCAG tags removed by axe-core', async () => { + const axe = await importAxeCore(); + const axeTags = axe.getRules().flatMap(rule => rule.tags); - it('should not have WCAG tags removed by axe-core', () => { expect(axeTags).toIncludeAllMembers(axeWcagTagSchema.options); }); }); diff --git a/packages/plugin-axe/src/lib/meta/audits.unit.test.ts b/packages/plugin-axe/src/lib/meta/audits.unit.test.ts index 95bd88189..d71e3f8c2 100644 --- a/packages/plugin-axe/src/lib/meta/audits.unit.test.ts +++ b/packages/plugin-axe/src/lib/meta/audits.unit.test.ts @@ -10,16 +10,15 @@ describe('transformRulesToAudits', () => { ['all', 100, 110], ])( 'should transform %j preset rules into audits within expected range', - (preset, min, max) => { - expect(transformRulesToAudits(loadAxeRules(preset))).toBeInRange( - min, - max, - ); + async (preset, min, max) => { + const rules = await loadAxeRules(preset); + expect(transformRulesToAudits(rules)).toBeInRange(min, max); }, ); - it('should include required metadata fields for all transformed audits', () => { - const audit = transformRulesToAudits(loadAxeRules('wcag21aa'))[0]!; + it('should include required metadata fields for all transformed audits', async () => { + const rules = await loadAxeRules('wcag21aa'); + const audit = transformRulesToAudits(rules)[0]!; expect(audit).toMatchObject({ slug: expect.any(String), diff --git a/packages/plugin-axe/src/lib/meta/groups.unit.test.ts b/packages/plugin-axe/src/lib/meta/groups.unit.test.ts index 788a31108..c3883cfd3 100644 --- a/packages/plugin-axe/src/lib/meta/groups.unit.test.ts +++ b/packages/plugin-axe/src/lib/meta/groups.unit.test.ts @@ -3,24 +3,27 @@ import type { Group } from '@code-pushup/models'; import { loadAxeRules, transformRulesToGroups } from './transform.js'; describe('transformRulesToGroups', () => { - it('should create category groups for "wcag21aa" preset', () => { - const groups = transformRulesToGroups(loadAxeRules('wcag21aa')); + it('should create category groups for "wcag21aa" preset', async () => { + const rules = await loadAxeRules('wcag21aa'); + const groups = transformRulesToGroups(rules); expect(groups.length).toBeGreaterThan(5); expect(groups).toPartiallyContain({ slug: 'aria', title: 'ARIA' }); expect(groups).toPartiallyContain({ slug: 'forms', title: 'Forms' }); }); - it('should create category groups for "wcag22aa" preset', () => { - const groups = transformRulesToGroups(loadAxeRules('wcag22aa')); + it('should create category groups for "wcag22aa" preset', async () => { + const rules = await loadAxeRules('wcag22aa'); + const groups = transformRulesToGroups(rules); expect(groups.length).toBeGreaterThan(5); expect(groups).toPartiallyContain({ slug: 'aria', title: 'ARIA' }); expect(groups).toPartiallyContain({ slug: 'forms', title: 'Forms' }); }); - it('should create category groups for "best-practice" preset', () => { - const groups = transformRulesToGroups(loadAxeRules('best-practice')); + it('should create category groups for "best-practice" preset', async () => { + const rules = await loadAxeRules('best-practice'); + const groups = transformRulesToGroups(rules); expect(groups.length).toBeGreaterThan(5); expect(groups).toPartiallyContain({ slug: 'aria', title: 'ARIA' }); @@ -30,8 +33,9 @@ describe('transformRulesToGroups', () => { }); }); - it('should create category groups for "all" preset', () => { - const groups = transformRulesToGroups(loadAxeRules('all')); + it('should create category groups for "all" preset', async () => { + const rules = await loadAxeRules('all'); + const groups = transformRulesToGroups(rules); expect(groups.length).toBeGreaterThan(10); expect(groups).toPartiallyContain({ slug: 'aria', title: 'ARIA' }); @@ -41,8 +45,9 @@ describe('transformRulesToGroups', () => { }); }); - it('should format category titles using display names', () => { - const groups = transformRulesToGroups(loadAxeRules('all')); + it('should format category titles using display names', async () => { + const rules = await loadAxeRules('all'); + const groups = transformRulesToGroups(rules); expect(groups).toPartiallyContain({ slug: 'aria', title: 'ARIA' }); expect(groups).toPartiallyContain({ @@ -55,22 +60,25 @@ describe('transformRulesToGroups', () => { }); }); - it('should not include "cat." prefix in group slugs', () => { - const groups = transformRulesToGroups(loadAxeRules('all')); + it('should not include "cat." prefix in group slugs', async () => { + const rules = await loadAxeRules('all'); + const groups = transformRulesToGroups(rules); expect(groups).toSatisfyAll(({ slug }) => !slug.startsWith('cat.')); }); - it('should assign equal weight to all audit references within groups', () => { - const groups = transformRulesToGroups(loadAxeRules('wcag21aa')); + it('should assign equal weight to all audit references within groups', async () => { + const rules = await loadAxeRules('wcag21aa'); + const groups = transformRulesToGroups(rules); expect(groups).toSatisfyAll(({ refs }) => refs.every(({ weight }) => weight === 1), ); }); - it('should filter out empty groups', () => { - const groups = transformRulesToGroups(loadAxeRules('all')); + it('should filter out empty groups', async () => { + const rules = await loadAxeRules('all'); + const groups = transformRulesToGroups(rules); expect(groups).toSatisfyAll(({ refs }) => refs.length > 0); }); diff --git a/packages/plugin-axe/src/lib/meta/processing.ts b/packages/plugin-axe/src/lib/meta/processing.ts index 55d951889..dbc9cd6bb 100644 --- a/packages/plugin-axe/src/lib/meta/processing.ts +++ b/packages/plugin-axe/src/lib/meta/processing.ts @@ -17,15 +17,15 @@ import { } from './transform.js'; /** Loads and processes Axe rules into audits and groups, expanding for multiple URLs if needed. */ -export function processAuditsAndGroups( +export async function processAuditsAndGroups( urls: string[], preset: AxePreset, -): { +): Promise<{ audits: Audit[]; groups: Group[]; ruleIds: string[]; -} { - const rules = loadAxeRules(preset); +}> { + const rules = await loadAxeRules(preset); const ruleIds = rules.map(({ ruleId }) => ruleId); const audits = transformRulesToAudits(rules); const groups = transformRulesToGroups(rules); diff --git a/packages/plugin-axe/src/lib/meta/processing.unit.test.ts b/packages/plugin-axe/src/lib/meta/processing.unit.test.ts index 5fd66d810..7fdbee16b 100644 --- a/packages/plugin-axe/src/lib/meta/processing.unit.test.ts +++ b/packages/plugin-axe/src/lib/meta/processing.unit.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { processAuditsAndGroups } from './processing.js'; describe('processAuditsAndGroups', () => { - it('should return audits and groups without expansion when analyzing single URL', () => { - const { audits, groups } = processAuditsAndGroups( + it('should return audits and groups without expansion when analyzing single URL', async () => { + const { audits, groups } = await processAuditsAndGroups( ['https://example.com'], 'wcag21aa', ); @@ -15,8 +15,8 @@ describe('processAuditsAndGroups', () => { expect(groups[0]!.slug).not.toContain('-1'); }); - it('should expand audits and groups when analyzing multiple URLs', () => { - const { audits, groups } = processAuditsAndGroups( + it('should expand audits and groups when analyzing multiple URLs', async () => { + const { audits, groups } = await processAuditsAndGroups( ['https://example.com', 'https://another-example.com'], 'wcag21aa', ); diff --git a/packages/plugin-axe/src/lib/meta/transform.ts b/packages/plugin-axe/src/lib/meta/transform.ts index f4e8ad03e..a0a000c49 100644 --- a/packages/plugin-axe/src/lib/meta/transform.ts +++ b/packages/plugin-axe/src/lib/meta/transform.ts @@ -1,4 +1,4 @@ -import axe from 'axe-core'; +import type { RuleMetadata } from 'axe-core'; import type { Audit, Group } from '@code-pushup/models'; import { objectToEntries, wrapTags } from '@code-pushup/utils'; import type { AxePreset } from '../config.js'; @@ -7,15 +7,22 @@ import { CATEGORY_GROUPS, getWcagPresetTags, } from '../groups.js'; +import { importAxeCore } from '../safe-axe-import.js'; + +let axeModule: any | null = null; /** Loads Axe rules filtered by the specified preset. */ -export function loadAxeRules(preset: AxePreset): axe.RuleMetadata[] { +export async function loadAxeRules(preset: AxePreset): Promise { + if (!axeModule) { + axeModule = await importAxeCore(); + } + const tags = getPresetTags(preset); - return tags.length === 0 ? axe.getRules() : axe.getRules(tags); + return tags.length === 0 ? axeModule.getRules() : axeModule.getRules(tags); } /** Transforms Axe rule metadata into Code PushUp audit definitions. */ -export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] { +export function transformRulesToAudits(rules: RuleMetadata[]): Audit[] { return rules.map(rule => ({ slug: rule.ruleId, title: wrapTags(rule.help), @@ -25,7 +32,7 @@ export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] { } /** Transforms Axe rules into Code PushUp groups based on accessibility categories. */ -export function transformRulesToGroups(rules: axe.RuleMetadata[]): Group[] { +export function transformRulesToGroups(rules: RuleMetadata[]): Group[] { const groups = createCategoryGroups(rules); return groups.filter(({ refs }) => refs.length > 0); } @@ -52,7 +59,7 @@ function getPresetTags(preset: AxePreset): string[] { function createGroup( slug: AxeGroupSlug, title: string, - rules: axe.RuleMetadata[], + rules: RuleMetadata[], ): Group { return { slug, @@ -61,7 +68,7 @@ function createGroup( }; } -function createCategoryGroups(rules: axe.RuleMetadata[]): Group[] { +function createCategoryGroups(rules: RuleMetadata[]): Group[] { return objectToEntries(CATEGORY_GROUPS).map(([slug, title]) => { const tag = `cat.${slug}`; const categoryRules = rules.filter(({ tags }) => tags.includes(tag)); diff --git a/packages/plugin-axe/src/lib/safe-axe-import.ts b/packages/plugin-axe/src/lib/safe-axe-import.ts new file mode 100644 index 000000000..85cc66072 --- /dev/null +++ b/packages/plugin-axe/src/lib/safe-axe-import.ts @@ -0,0 +1,56 @@ +import { JSDOM } from 'jsdom'; + +/** + * Executes a function with a temporary DOM environment set up. + * Global state is automatically restored after execution. + * + * This is the recommended pattern for working with DOM-dependent libraries + * in Node.js environments. It provides proper isolation and cleanup. + * + * @param html - HTML string to initialize the DOM with + * @param fn - Function to execute with the DOM environment + * @returns The result of the function execution + */ +export async function withDom( + html: string, + fn: () => Promise | T, +): Promise { + const { JSDOM } = await import('jsdom'); + + // Save previous global state + const previous = { + window: (globalThis as any).window, + document: (globalThis as any).document, + Node: (globalThis as any).Node, + }; + + // Set up DOM environment + const dom = new JSDOM(html); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + Node: dom.window.Node, + }); + + try { + return await fn(); + } finally { + // Restore previous global state + Object.assign(globalThis, previous); + } +} + +/** + * Safely imports axe-core by setting up the DOM environment first. + * + * This is the only correct pattern for importing axe-core in Node.js. + * Always set up the DOM before importing axe-core, never after. + * + * @returns The axe-core module + */ +export async function importAxeCore(): Promise { + return withDom('', async () => { + const axe = await import('axe-core'); + return axe.default; + }); +} diff --git a/packages/plugin-axe/src/lib/safe-axe-import.unit.test.ts b/packages/plugin-axe/src/lib/safe-axe-import.unit.test.ts new file mode 100644 index 000000000..dc2b0e8b4 --- /dev/null +++ b/packages/plugin-axe/src/lib/safe-axe-import.unit.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { withDom } from './safe-axe-import.js'; + +describe('withDom', () => { + it('should set up DOM globals during function execution', async () => { + const originalWindow = (globalThis as any).window; + const originalDocument = (globalThis as any).document; + const originalNode = (globalThis as any).Node; + + let capturedGlobals: any = {}; + + await withDom( + '
Hello
', + async () => { + capturedGlobals = { + window: (globalThis as any).window, + document: (globalThis as any).document, + Node: (globalThis as any).Node, + }; + + // Verify DOM globals are set + expect(capturedGlobals.window).toBeDefined(); + expect(capturedGlobals.document).toBeDefined(); + expect(capturedGlobals.Node).toBeDefined(); + + // Verify we can access DOM elements + const testElement = capturedGlobals.document.getElementById('test'); + expect(testElement).toBeDefined(); + expect(testElement?.textContent).toBe('Hello'); + + return 'success'; + }, + ); + + // Verify globals are restored + expect((globalThis as any).window).toBe(originalWindow); + expect((globalThis as any).document).toBe(originalDocument); + expect((globalThis as any).Node).toBe(originalNode); + }); + + it('should restore globals even if function throws', async () => { + const originalWindow = (globalThis as any).window; + const originalDocument = (globalThis as any).document; + + let globalsWereSet = false; + + await expect( + withDom('', async () => { + globalsWereSet = (globalThis as any).window !== originalWindow; + throw new Error('Test error'); + }), + ).rejects.toThrow('Test error'); + + // Verify globals were set during execution + expect(globalsWereSet).toBe(true); + + // Verify globals are restored after error + expect((globalThis as any).window).toBe(originalWindow); + expect((globalThis as any).document).toBe(originalDocument); + }); + + it('should return the function result', async () => { + const result = await withDom('', async () => { + return { success: true, value: 42 }; + }); + + expect(result).toEqual({ success: true, value: 42 }); + }); +}); diff --git a/packages/plugin-coverage/eslint.config.js b/packages/plugin-coverage/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-coverage/eslint.config.js +++ b/packages/plugin-coverage/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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-eslint/eslint.config.js b/packages/plugin-eslint/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-eslint/eslint.config.js +++ b/packages/plugin-eslint/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/packages/plugin-js-packages/eslint.config.js b/packages/plugin-js-packages/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-js-packages/eslint.config.js +++ b/packages/plugin-js-packages/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/packages/plugin-jsdocs/eslint.config.js b/packages/plugin-jsdocs/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-jsdocs/eslint.config.js +++ b/packages/plugin-jsdocs/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/packages/plugin-lighthouse/eslint.config.js b/packages/plugin-lighthouse/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-lighthouse/eslint.config.js +++ b/packages/plugin-lighthouse/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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/eslint.config.js b/packages/plugin-typescript/eslint.config.js index 40165321a..ca61d55f3 100644 --- a/packages/plugin-typescript/eslint.config.js +++ b/packages/plugin-typescript/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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/eslint.config.js b/packages/utils/eslint.config.js index 1ad01224a..702cfae12 100644 --- a/packages/utils/eslint.config.js +++ b/packages/utils/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { 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/project.json b/project.json index 48e03fd94..c40f924ce 100644 --- a/project.json +++ b/project.json @@ -2,6 +2,16 @@ "name": "workspace", "$schema": "node_modules/nx/schemas/project-schema.json", "targets": { + "jiti-test": { + "executor": "nx:run-commands", + "options": { + "command": "node -e \"console.log('Testing jiti path aliases:'); console.log('NODE_OPTIONS:', process.env.NODE_OPTIONS); console.log('JITI_TSCONFIG_PATH:', process.env.JITI_TSCONFIG_PATH); console.log('Testing import of @push-based/jiti-tsc...'); try { require('@push-based/jiti-tsc'); console.log('✓ jiti-tsc imported successfully'); } catch (e) { console.log('✗ Failed to import jiti-tsc:', e.message); }\"", + "env": { + "NODE_OPTIONS": "--import @push-based/jiti-tsc", + "JITI_TSCONFIG_PATH": "tsconfig.base.json" + } + } + }, "code-pushup": { "dependsOn": [], "options": { diff --git a/testing/test-fixtures/eslint.config.js b/testing/test-fixtures/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/testing/test-fixtures/eslint.config.js +++ b/testing/test-fixtures/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); 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"] } } } diff --git a/testing/test-nx-utils/eslint.config.js b/testing/test-nx-utils/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/testing/test-nx-utils/eslint.config.js +++ b/testing/test-nx-utils/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/testing/test-setup-config/eslint.config.js b/testing/test-setup-config/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/testing/test-setup-config/eslint.config.js +++ b/testing/test-setup-config/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/testing/test-setup/eslint.config.js b/testing/test-setup/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/testing/test-setup/eslint.config.js +++ b/testing/test-setup/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/testing/test-utils/eslint.config.js b/testing/test-utils/eslint.config.js index 2656b27cb..05c619c08 100644 --- a/testing/test-utils/eslint.config.js +++ b/testing/test-utils/eslint.config.js @@ -1,12 +1,15 @@ 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, +export default tseslint.config( + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, }, }, -}); +); diff --git a/tools/eslint-formatter-multi/eslint.config.js b/tools/eslint-formatter-multi/eslint.config.js index 29bda515b..0779f9a35 100644 --- a/tools/eslint-formatter-multi/eslint.config.js +++ b/tools/eslint-formatter-multi/eslint.config.js @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; export default tseslint.config( - ...baseConfig, + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), { files: ['**/*.ts'], languageOptions: { diff --git a/tools/zod2md-jsdocs/eslint.config.js b/tools/zod2md-jsdocs/eslint.config.js index 467b6c94b..4bfdd9419 100644 --- a/tools/zod2md-jsdocs/eslint.config.js +++ b/tools/zod2md-jsdocs/eslint.config.js @@ -1,11 +1,14 @@ const baseConfig = require('../../eslint.config.js').default; -module.exports = [ - ...baseConfig, - { - files: ['**/*.json'], - rules: { - '@nx/dependency-checks': 'error', +// eslint-disable-next-line unicorn/prefer-top-level-await, arrow-body-style +module.exports = (async () => { + return [ + ...(await (typeof baseConfig === 'function' ? baseConfig() : baseConfig)), + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': 'error', + }, }, - }, -]; + ]; +})();