diff --git a/.gitignore b/.gitignore index 5e706fa10..e6c77e8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ node_modules /.sass-cache /connect.lock /coverage +**/.coverage/** /examples/react-todos-app/coverage /libpeerconnection.log npm-debug.log diff --git a/e2e/ci-e2e/vitest.e2e.config.ts b/e2e/ci-e2e/vitest.e2e.config.ts index 188911141..37ea143d8 100644 --- a/e2e/ci-e2e/vitest.e2e.config.ts +++ b/e2e/ci-e2e/vitest.e2e.config.ts @@ -1,22 +1,17 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { createE2eConfig } from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/ci-e2e', - test: { - reporters: ['basic'], - testTimeout: 60_000, - globals: true, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - cache: { - dir: '../../node_modules/.vitest', +export default createE2eConfig( + 'ci-e2e', + { + projectRoot: new URL('../../', import.meta.url), + cacheKey: 'ci-e2e', + }, + { + test: { + testTimeout: 60_000, + globalSetup: ['./global-setup.ts'], + coverage: { enabled: false }, }, - environment: 'node', - include: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - globalSetup: './global-setup.ts', - setupFiles: ['../../testing/test-setup/src/lib/reset.mocks.ts'], }, -}); +); diff --git a/e2e/plugin-typescript-e2e/vitest.e2e.config.ts b/e2e/plugin-typescript-e2e/vitest.e2e.config.ts index 8f30d049b..1e9fd4768 100644 --- a/e2e/plugin-typescript-e2e/vitest.e2e.config.ts +++ b/e2e/plugin-typescript-e2e/vitest.e2e.config.ts @@ -1,26 +1,16 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { createE2eConfig } from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/plugin-typescript-e2e', - test: { - reporters: ['basic'], - testTimeout: 20_000, - globals: true, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - coverage: { - reporter: ['text', 'lcov'], - reportsDirectory: '../../coverage/plugin-typescript-e2e/e2e-tests', - exclude: ['mocks/**', '**/types.ts'], - }, - cache: { - dir: '../../node_modules/.vitest', +export default createE2eConfig( + 'plugin-typescript-e2e', + { + projectRoot: new URL('../../', import.meta.url), + cacheKey: 'plugin-typescript-e2e', + }, + { + test: { + testTimeout: 20_000, + coverage: { enabled: true }, }, - environment: 'node', - include: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - setupFiles: ['../../testing/test-setup/src/lib/reset.mocks.ts'], }, -}); +); diff --git a/packages/core/vitest.int.config.ts b/packages/core/vitest.int.config.ts index 819c3a5bb..981ee4e19 100644 --- a/packages/core/vitest.int.config.ts +++ b/packages/core/vitest.int.config.ts @@ -1,30 +1,17 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { + createIntConfig, + setupPresets, +} from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/core', - test: { - reporters: ['basic'], - globals: true, - cache: { - dir: '../../node_modules/.vitest', - }, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - coverage: { - reporter: ['text', 'lcov'], - reportsDirectory: '../../coverage/core/int-tests', - exclude: ['mocks/**', '**/types.ts'], +export default createIntConfig( + 'core', + { + projectRoot: new URL('../../', import.meta.url), + }, + { + test: { + setupFiles: [...setupPresets.int.base, ...setupPresets.int.portalClient], }, - environment: 'node', - include: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - globalSetup: ['../../global-setup.ts'], - setupFiles: [ - '../../testing/test-setup/src/lib/console.mock.ts', - '../../testing/test-setup/src/lib/reset.mocks.ts', - '../../testing/test-setup/src/lib/portal-client.mock.ts', - ], }, -}); +); diff --git a/packages/core/vitest.unit.config.ts b/packages/core/vitest.unit.config.ts index 820eb538c..d11f288f9 100644 --- a/packages/core/vitest.unit.config.ts +++ b/packages/core/vitest.unit.config.ts @@ -1,36 +1,22 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { + createUnitConfig, + setupPresets, +} from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/core', - test: { - reporters: ['basic'], - globals: true, - cache: { - dir: '../../node_modules/.vitest', - }, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - coverage: { - reporter: ['text', 'lcov'], - reportsDirectory: '../../coverage/core/unit-tests', - exclude: ['mocks/**', '**/types.ts'], +export default createUnitConfig( + 'core', + { + projectRoot: new URL('../../', import.meta.url), + }, + { + test: { + setupFiles: [ + ...setupPresets.unit.base, + ...setupPresets.unit.git, + ...setupPresets.unit.portalClient, + ...setupPresets.unit.matchersCore, + ], }, - environment: 'node', - include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - globalSetup: ['../../global-setup.ts'], - setupFiles: [ - '../../testing/test-setup/src/lib/cliui.mock.ts', - '../../testing/test-setup/src/lib/fs.mock.ts', - '../../testing/test-setup/src/lib/git.mock.ts', - '../../testing/test-setup/src/lib/console.mock.ts', - '../../testing/test-setup/src/lib/reset.mocks.ts', - '../../testing/test-setup/src/lib/portal-client.mock.ts', - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', - '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', - ], }, -}); +); diff --git a/packages/utils/vitest.int.config.ts b/packages/utils/vitest.int.config.ts index 61c01ddad..1aeadf3d4 100644 --- a/packages/utils/vitest.int.config.ts +++ b/packages/utils/vitest.int.config.ts @@ -1,30 +1,18 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { + createIntConfig, + setupPresets, +} from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/utils', - test: { - reporters: ['basic'], - globals: true, - cache: { - dir: '../../node_modules/.vitest', - }, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - coverage: { - reporter: ['text', 'lcov'], - reportsDirectory: '../../coverage/utils/int-tests', - exclude: ['mocks/**', 'perf/**', '**/types.ts'], +export default createIntConfig( + 'utils', + { + projectRoot: new URL('../../', import.meta.url), + }, + { + test: { + coverage: { exclude: ['perf/**'] }, + setupFiles: [...setupPresets.int.base, ...setupPresets.int.cliui], }, - environment: 'node', - include: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - globalSetup: ['../../global-setup.ts'], - setupFiles: [ - '../../testing/test-setup/src/lib/cliui.mock.ts', - '../../testing/test-setup/src/lib/console.mock.ts', - '../../testing/test-setup/src/lib/reset.mocks.ts', - ], }, -}); +); diff --git a/packages/utils/vitest.unit.config.ts b/packages/utils/vitest.unit.config.ts index ac74f7af6..66bb6dd51 100644 --- a/packages/utils/vitest.unit.config.ts +++ b/packages/utils/vitest.unit.config.ts @@ -1,38 +1,24 @@ /// -import { defineConfig } from 'vite'; -import { tsconfigPathAliases } from '../../tools/vitest-tsconfig-path-aliases.js'; +import { + createUnitConfig, + setupPresets, +} from '../../testing/test-setup-config/src/lib/vitest-setup-presets.js'; -export default defineConfig({ - cacheDir: '../../node_modules/.vite/utils', - test: { - reporters: ['basic'], - globals: true, - cache: { - dir: '../../node_modules/.vitest', - }, - alias: tsconfigPathAliases(), - pool: 'threads', - poolOptions: { threads: { singleThread: true } }, - coverage: { - reporter: ['text', 'lcov'], - reportsDirectory: '../../coverage/utils/unit-tests', - exclude: ['mocks/**', 'perf/**', '**/types.ts'], - }, - environment: 'node', - include: ['src/**/*.{unit,type}.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - typecheck: { - include: ['**/*.type.test.ts'], +export default createUnitConfig( + 'utils', + { + projectRoot: new URL('../../', import.meta.url), + }, + { + test: { + include: ['src/**/*.{unit,type}.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + typecheck: { include: ['**/*.type.test.ts'] }, + coverage: { exclude: ['perf/**'] }, + setupFiles: [ + ...setupPresets.unit.base, + ...setupPresets.unit.matchersCore, + ...setupPresets.unit.matcherPath, + ], }, - globalSetup: ['../../global-setup.ts'], - setupFiles: [ - '../../testing/test-setup/src/lib/cliui.mock.ts', - '../../testing/test-setup/src/lib/fs.mock.ts', - '../../testing/test-setup/src/lib/console.mock.ts', - '../../testing/test-setup/src/lib/reset.mocks.ts', - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', - '../../testing/test-setup/src/lib/extend/path.matcher.ts', - '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', - ], }, -}); +); diff --git a/testing/test-setup-config/README.md b/testing/test-setup-config/README.md new file mode 100644 index 000000000..42aa20ae8 --- /dev/null +++ b/testing/test-setup-config/README.md @@ -0,0 +1,24 @@ +## Vitest config factory and setup presets + +Utilities to centralize and standardize Vitest configuration across the monorepo. + +- `vitest-config-factory.ts`: builds typed Vitest configs with sensible defaults +- `vitest-setup-presets.ts`: provides create functions and exportable setup file groups + +The create functions (`createUnitConfig`, `createIntConfig`, `createE2eConfig`) automatically include appropriate setup files for each test type. See the unit tests for detailed documentation of defaults, coverage settings, and setup file presets. + +### Examples + +**Using defaults:** + +```ts +export default createUnitConfig('my-package', import.meta.url); +``` + +**Extending default setup files:** + +```ts +export default createIntConfig('my-package', import.meta.url, { + setupFiles: [...setupPresets.int.base, ...setupPresets.int.git, './custom-setup.ts'], +}); +``` diff --git a/testing/test-setup-config/eslint.config.js b/testing/test-setup-config/eslint.config.js new file mode 100644 index 000000000..2656b27cb --- /dev/null +++ b/testing/test-setup-config/eslint.config.js @@ -0,0 +1,12 @@ +import tseslint from 'typescript-eslint'; +import baseConfig from '../../eslint.config.js'; + +export default tseslint.config(...baseConfig, { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/testing/test-setup-config/project.json b/testing/test-setup-config/project.json new file mode 100644 index 000000000..c4b32ba8a --- /dev/null +++ b/testing/test-setup-config/project.json @@ -0,0 +1,12 @@ +{ + "name": "test-setup-config", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "testing/test-setup/src", + "projectType": "library", + "targets": { + "build": {}, + "lint": {}, + "unit-test": {} + }, + "tags": ["scope:shared", "type:testing"] +} diff --git a/testing/test-setup-config/src/index.ts b/testing/test-setup-config/src/index.ts new file mode 100644 index 000000000..ef10c13bc --- /dev/null +++ b/testing/test-setup-config/src/index.ts @@ -0,0 +1,14 @@ +export { + createVitestConfig, + type TestKind, + type VitestConfigFactoryOptions, + type VitestOverrides, + type ConfigRestParams, +} from './lib/vitest-config-factory.js'; + +export { + setupPresets, + createUnitConfig, + createIntConfig, + createE2eConfig, +} from './lib/vitest-setup-presets.js'; diff --git a/testing/test-setup-config/src/lib/vitest-config-factory.ts b/testing/test-setup-config/src/lib/vitest-config-factory.ts new file mode 100644 index 000000000..31882ce9a --- /dev/null +++ b/testing/test-setup-config/src/lib/vitest-config-factory.ts @@ -0,0 +1,223 @@ +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + type UserConfig as ViteUserConfig, + defineConfig, + mergeConfig, +} from 'vite'; +import type { CoverageOptions, InlineConfig } from 'vitest'; +import { tsconfigPathAliases } from './vitest-tsconfig-path-aliases.js'; + +export type TestKind = 'unit' | 'int' | 'e2e'; + +export type VitestConfigFactoryOptions = { + /** + * Used for cache and coverage directory naming + */ + projectKey: string; + /** + * Required path/URL to the project root for resolving all paths + */ + kind: TestKind; + /** + * The root directory of the project + */ + projectRoot: string | URL; + /** + * Optional test cache directory name + */ + cacheKey?: string; +}; + +export type VitestOverrides = ViteUserConfig & { test?: InlineConfig }; +export type ConfigRestParams = Pick< + VitestConfigFactoryOptions, + 'projectRoot' | 'cacheKey' +>; + +export function createVitestConfig( + options: VitestConfigFactoryOptions, + overrides: VitestOverrides = {}, +): ViteUserConfig { + const projectRootUrl: URL = + typeof options.projectRoot === 'string' + ? pathToFileURL( + options.projectRoot.endsWith('/') + ? options.projectRoot + : `${options.projectRoot}/`, + ) + : options.projectRoot; + const cacheDirName = options.cacheKey ?? `cache-${options.projectKey}`; + + const coverageEnabled = + overrides.test?.coverage?.enabled ?? options.kind !== 'e2e'; + + const overrideSetupFiles = overrides.test?.setupFiles; + const setupFiles = overrideSetupFiles + ? toAbsolutePaths(projectRootUrl, normalizeSetupFiles(overrideSetupFiles)) + : []; + + const baseConfig = buildBaseConfig({ + projectKey: options.projectKey, + kind: options.kind, + projectRootUrl, + cacheDirName, + coverageEnabled, + setupFiles, + overrideExclude: + (overrides.test?.coverage?.exclude as string[] | undefined) ?? [], + }); + + const normalizedOverrides = sanitizeOverrides(overrides); + const merged = mergeConfig( + baseConfig as ViteUserConfig, + normalizedOverrides as ViteUserConfig, + ); + return defineConfig(merged); +} + +function toAbsolutePaths( + projectRootUrl: URL, + paths?: readonly string[], +): string[] { + return paths && paths.length > 0 + ? paths + .filter(Boolean) + .map(p => path.resolve(getProjectRootPath(projectRootUrl), p)) + : []; +} + +function normalizeSetupFiles(setupFiles: string | readonly string[]): string[] { + return Array.isArray(setupFiles) + ? (setupFiles as string[]) + : [setupFiles as string]; +} + +function defaultInclude(kind: TestKind): string[] { + return kind === 'unit' + ? ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] + : kind === 'int' + ? ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] + : ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']; +} + +function defaultGlobalSetup( + kind: TestKind, + projectRootUrl: URL, +): string[] | undefined { + return kind === 'e2e' + ? undefined + : [path.resolve(getProjectRootPath(projectRootUrl), 'global-setup.ts')]; +} + +function buildCoverageConfig(params: { + projectKey: string; + kind: TestKind; + projectRootUrl: URL; + overrideExclude?: string[]; +}): CoverageOptions { + const defaultExclude = ['mocks/**', '**/types.ts']; + const reportsDirectory = path.resolve( + getProjectRootPath(params.projectRootUrl), + params.kind === 'e2e' + ? `e2e/${params.projectKey}/.coverage` + : `packages/${params.projectKey}/.coverage/${params.kind}-tests`, + ); + return { + reporter: ['text', 'lcov'], + reportsDirectory, + exclude: + params.overrideExclude && params.overrideExclude.length > 0 + ? [...defaultExclude, ...params.overrideExclude] + : defaultExclude, + }; +} + +function buildBaseConfig(params: { + projectKey: string; + kind: TestKind; + projectRootUrl: URL; + cacheDirName: string; + coverageEnabled: boolean; + setupFiles: string[]; + overrideExclude: string[]; +}): VitestOverrides { + const cfg: VitestOverrides = { + cacheDir: path.resolve( + getProjectRootPath(params.projectRootUrl), + `node_modules/.vite/${params.cacheDirName}`, + ), + test: { + reporters: ['basic'], + globals: true, + cache: { + dir: path.resolve( + getProjectRootPath(params.projectRootUrl), + 'node_modules/.vitest', + ), + }, + alias: tsconfigPathAliases(params.projectRootUrl), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + environment: 'node', + include: defaultInclude(params.kind), + globalSetup: defaultGlobalSetup(params.kind, params.projectRootUrl), + setupFiles: params.setupFiles, + ...(params.coverageEnabled + ? { + coverage: buildCoverageConfig({ + projectKey: params.projectKey, + kind: params.kind, + projectRootUrl: params.projectRootUrl, + overrideExclude: params.overrideExclude, + }), + } + : {}), + }, + }; + return cfg; +} + +function sanitizeCoverageOptions( + coverage: unknown, +): CoverageOptions | undefined { + if (!coverage) { + return undefined; + } + + const { + enabled: _en, + exclude: _ex, + ...rest + } = coverage as CoverageOptions & { + enabled?: boolean; + exclude?: string[]; + }; + return rest as CoverageOptions; +} + +function sanitizeOverrides(overrides: VitestOverrides): VitestOverrides { + if (!overrides?.test) { + return overrides; + } + + // Remove setupFiles from sanitization since we handle it directly in main logic + const { setupFiles: _sf, coverage, ...restTest } = overrides.test; + const sanitizedCoverage = sanitizeCoverageOptions(coverage); + + const sanitizedTest: InlineConfig = sanitizedCoverage + ? { ...restTest, coverage: sanitizedCoverage } + : restTest; + + return { ...overrides, test: sanitizedTest }; +} + +export function getProjectRootPath(projectRootUrl: URL): string { + try { + return fileURLToPath(projectRootUrl); + } catch { + // Fallback for non-file:// URLs or invalid URLs + const pathname = projectRootUrl.pathname; + return pathname.startsWith('/') ? pathname : `/${pathname}`; + } +} diff --git a/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts b/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts new file mode 100644 index 000000000..9eae528fb --- /dev/null +++ b/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts @@ -0,0 +1,744 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { defineConfig } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type TestKind, + type VitestConfigFactoryOptions, + type VitestOverrides, + createVitestConfig, + getProjectRootPath, +} from './vitest-config-factory.js'; + +// Only mock defineConfig - assume it works correctly, we're not testing Vite +// Use importOriginal to keep mergeConfig real while mocking defineConfig +vi.mock('vite', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + defineConfig: vi.fn(config => config), + }; +}); + +// Mock tsconfigPathAliases since it reads from filesystem and our fake paths don't exist +vi.mock('./vitest-tsconfig-path-aliases.js', () => ({ + tsconfigPathAliases: vi.fn().mockReturnValue({ '@mock/alias': '/mock/path' }), +})); + +const MOCK_PROJECT_ROOT_STRING = '/Users/test/project'; +const MOCK_PROJECT_ROOT_URL = pathToFileURL(`${MOCK_PROJECT_ROOT_STRING}/`); + +// Simple path helpers - just use them directly in tests! +const mockPath = (...segments: string[]) => + path.resolve(MOCK_PROJECT_ROOT_STRING, ...segments); +const mockUrlPath = (url: URL, ...segments: string[]) => + path.resolve(getProjectRootPath(url), ...segments); + +const TEST_TIMEOUTS = { + SHORT: 5000, + MEDIUM: 10_000, + LONG: 30_000, +} as const; + +const EXPECTED_INCLUDES = { + unit: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + int: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + e2e: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], +} as const; + +const DEFAULT_EXCLUDES = ['mocks/**', '**/types.ts'] as const; + +const expectCoverageConfig = (config: any, expectedProps: Partial) => { + expect(config.test.coverage).toEqual(expect.objectContaining(expectedProps)); +}; + +describe('createVitestConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('basic functionality', () => { + it('should create a basic unit test config with string projectRoot', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + cacheDir: mockPath('node_modules', '.vite', 'cache-test-package'), + test: expect.objectContaining({ + reporters: ['basic'], + globals: true, + cache: { + dir: mockPath('node_modules', '.vitest'), + }, + alias: expect.any(Object), + pool: 'threads', + poolOptions: { threads: { singleThread: true } }, + environment: 'node', + include: EXPECTED_INCLUDES.unit, + globalSetup: [mockPath('global-setup.ts')], + setupFiles: [], + coverage: expect.objectContaining({ + reporter: ['text', 'lcov'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: DEFAULT_EXCLUDES, + }), + }), + }), + ); + expect(defineConfig).toHaveBeenCalledWith(config); + expect(defineConfig).toHaveBeenCalledTimes(1); + }); + + it('should create a basic unit test config with URL projectRoot', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_URL, + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + cacheDir: mockUrlPath( + MOCK_PROJECT_ROOT_URL, + 'node_modules', + '.vite', + 'cache-test-package', + ), + test: expect.objectContaining({ + include: EXPECTED_INCLUDES.unit, + globalSetup: [ + mockUrlPath(MOCK_PROJECT_ROOT_URL, 'global-setup.ts'), + ], + }), + }), + ); + }); + + it('should handle projectRoot string without trailing slash', () => { + const projectRoot = '/Users/test/project'; + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot, + }; + + const config = createVitestConfig(options); + + expect((config as any).test.alias).toBeDefined(); + }); + + it('should handle projectRoot string with trailing slash', () => { + const projectRoot = '/Users/test/project/'; + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot, + }; + + const config = createVitestConfig(options); + + expect((config as any).test.alias).toBeDefined(); + }); + }); + + describe('test kind variations', () => { + it('should create integration test config', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'int', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + include: EXPECTED_INCLUDES.int, + globalSetup: [mockPath('global-setup.ts')], + coverage: expect.objectContaining({ + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'int-tests', + ), + }), + }), + }), + ); + }); + + it('should create e2e test config without coverage by default', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'e2e', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + include: EXPECTED_INCLUDES.e2e, + globalSetup: undefined, + }), + }), + ); + + expect((config as any).test.coverage).toBeUndefined(); + }); + + it('should create e2e test config with coverage when explicitly enabled', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'e2e', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: { + enabled: true, + }, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + include: EXPECTED_INCLUDES.e2e, + globalSetup: undefined, + coverage: expect.objectContaining({ + reporter: ['text', 'lcov'], + reportsDirectory: mockPath('e2e', 'test-package', '.coverage'), + exclude: DEFAULT_EXCLUDES, + }), + }), + }), + ); + }); + }); + + describe('cacheKey option', () => { + it('should use cacheKey when provided', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + cacheKey: 'custom-cache-key', + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + cacheDir: mockPath('node_modules', '.vite', 'custom-cache-key'), + }), + ); + }); + + it('should fallback to cache-{projectKey} when cacheKey is not provided', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options); + + expect(config).toEqual( + expect.objectContaining({ + cacheDir: mockPath('node_modules', '.vite', 'cache-test-package'), + }), + ); + }); + }); + + describe('setupFiles handling', () => { + it('should handle setupFiles as string in overrides', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: 'setup.ts', + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + setupFiles: [mockPath('setup.ts')], + }), + }), + ); + }); + + it('should handle setupFiles as array in overrides', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: ['setup1.ts', 'setup2.ts'], + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + setupFiles: [mockPath('setup1.ts'), mockPath('setup2.ts')], + }), + }), + ); + }); + + it('should filter out falsy values from setupFiles', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: ['setup1.ts', '', 'setup2.ts', null as any, 'setup3.ts'], + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + setupFiles: [ + mockPath('setup1.ts'), + mockPath('setup2.ts'), + mockPath('setup3.ts'), + ], + }), + }), + ); + }); + + it('should handle empty setupFiles array', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: [], + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + setupFiles: [], + }), + }), + ); + }); + + it('should use empty setupFiles when not provided in overrides', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options, {}); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + setupFiles: [], + }), + }), + ); + }); + }); + + describe('coverage configuration', () => { + it('should apply custom coverage exclude paths', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: { + exclude: ['custom/**', 'ignore/**'], + }, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + coverage: expect.objectContaining({ + reporter: ['text', 'lcov'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: [...DEFAULT_EXCLUDES, 'custom/**', 'ignore/**'], + }), + }), + }), + ); + }); + + it('should use default exclude when no custom excludes provided', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: { + exclude: [], + }, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + test: expect.objectContaining({ + coverage: expect.objectContaining({ + reporter: ['text', 'lcov'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: DEFAULT_EXCLUDES, + }), + }), + }), + ); + }); + + it('should disable coverage when explicitly disabled', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: { + enabled: false, + }, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect((config as any).test.coverage).toStrictEqual({}); + expect(config).toBeDefined(); + }); + + it('should sanitize coverage options by removing enabled and exclude from overrides', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: { + enabled: true, + exclude: ['custom/**'], + reporter: ['html', 'json'], + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, + }, + }; + + const config = createVitestConfig(options, overrides); + expectCoverageConfig(config, { + reporter: ['text', 'lcov', 'html', 'json'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: [...DEFAULT_EXCLUDES, 'custom/**'], + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }); + + expect(config).toBeDefined(); + }); + }); + + describe('config merging and sanitization', () => { + it('should merge base config with overrides', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + pool: 'forks' as any, + }, + }; + + const config = createVitestConfig(options, overrides); + + const testConfig = (config as any).test; + expect(testConfig.testTimeout).toBe(TEST_TIMEOUTS.MEDIUM); + expect(testConfig.pool).toBe('forks'); + }); + + it('should sanitize overrides by removing setupFiles from test config', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: ['should-be-removed.ts'], + testTimeout: TEST_TIMEOUTS.SHORT, + pool: 'forks' as any, + }, + }; + + const config = createVitestConfig(options, overrides); + + const testConfig = (config as any).test; + expect(testConfig.setupFiles).toEqual([mockPath('should-be-removed.ts')]); + expect(testConfig.testTimeout).toBe(TEST_TIMEOUTS.SHORT); + expect(testConfig.pool).toBe('forks'); + }); + + it('should handle overrides without test config', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + build: { + target: 'node14', + }, + }; + + const config = createVitestConfig(options, overrides); + + expect((config as any).build.target).toBe('node14'); + }); + + it('should handle coverage options as undefined', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: undefined, + testTimeout: TEST_TIMEOUTS.SHORT, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect((config as any).test.testTimeout).toBe(TEST_TIMEOUTS.SHORT); + expectCoverageConfig(config, { + reporter: ['text', 'lcov'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: DEFAULT_EXCLUDES, + }); + }); + + it('should handle coverage options as null', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'unit', + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const overrides: VitestOverrides = { + test: { + coverage: null as any, + testTimeout: TEST_TIMEOUTS.SHORT, + }, + }; + + const config = createVitestConfig(options, overrides); + + expect((config as any).test.testTimeout).toBe(TEST_TIMEOUTS.SHORT); + expectCoverageConfig(config, { + reporter: ['text', 'lcov'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'unit-tests', + ), + exclude: DEFAULT_EXCLUDES, + }); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle all test kinds correctly', () => { + const testKinds: TestKind[] = ['unit', 'int', 'e2e']; + + testKinds.forEach(kind => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind, + projectRoot: MOCK_PROJECT_ROOT_STRING, + }; + + const config = createVitestConfig(options); + + const expectedIncludes = { + unit: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + int: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + e2e: ['tests/**/*.e2e.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + }; + + expect((config as any).test.include).toStrictEqual( + expectedIncludes[kind], + ); + expect((config as any).test.globalSetup).toStrictEqual( + kind === 'e2e' ? undefined : [mockPath('global-setup.ts')], + ); + }); + }); + + it('should handle complex override scenarios', () => { + const options: VitestConfigFactoryOptions = { + projectKey: 'test-package', + kind: 'int', + projectRoot: MOCK_PROJECT_ROOT_STRING, + cacheKey: 'complex-scenario', + }; + + const overrides: VitestOverrides = { + test: { + setupFiles: ['setup1.ts', 'setup2.ts'], + coverage: { + enabled: true, + exclude: ['e2e/**', 'dist/**'], + reporter: ['lcov', 'text-summary'], + thresholds: { + global: { + statements: 90, + branches: 85, + functions: 90, + lines: 90, + }, + }, + }, + testTimeout: TEST_TIMEOUTS.LONG, + environment: 'jsdom' as any, + }, + build: { + target: 'es2020', + }, + }; + + const config = createVitestConfig(options, overrides); + + expect(config).toEqual( + expect.objectContaining({ + cacheDir: mockPath('node_modules', '.vite', 'complex-scenario'), + build: { + target: 'es2020', + }, + test: expect.objectContaining({ + setupFiles: [mockPath('setup1.ts'), mockPath('setup2.ts')], + testTimeout: TEST_TIMEOUTS.LONG, + environment: 'jsdom', + include: EXPECTED_INCLUDES.int, + coverage: expect.objectContaining({ + exclude: ['mocks/**', '**/types.ts', 'e2e/**', 'dist/**'], + reportsDirectory: mockPath( + 'packages', + 'test-package', + '.coverage', + 'int-tests', + ), + }), + }), + }), + ); + }); + }); +}); diff --git a/testing/test-setup-config/src/lib/vitest-setup-presets.ts b/testing/test-setup-config/src/lib/vitest-setup-presets.ts new file mode 100644 index 000000000..d887747fa --- /dev/null +++ b/testing/test-setup-config/src/lib/vitest-setup-presets.ts @@ -0,0 +1,84 @@ +import { + type ConfigRestParams, + type VitestOverrides, + createVitestConfig, +} from './vitest-config-factory.js'; + +const CONSOLE_MOCK_PATH = 'testing/test-setup/src/lib/console.mock.ts'; +const RESET_MOCKS_PATH = 'testing/test-setup/src/lib/reset.mocks.ts'; + +export const setupPresets = { + unit: { + base: [ + CONSOLE_MOCK_PATH, + RESET_MOCKS_PATH, + 'testing/test-setup/src/lib/cliui.mock.ts', + 'testing/test-setup/src/lib/fs.mock.ts', + 'testing/test-setup/src/lib/extend/ui-logger.matcher.ts', + ], + git: ['testing/test-setup/src/lib/git.mock.ts'], + portalClient: ['testing/test-setup/src/lib/portal-client.mock.ts'], + matchersCore: [ + 'testing/test-setup/src/lib/extend/markdown-table.matcher.ts', + 'testing/test-setup/src/lib/extend/jest-extended.matcher.ts', + ], + matcherPath: ['testing/test-setup/src/lib/extend/path.matcher.ts'], + }, + int: { + base: [CONSOLE_MOCK_PATH, RESET_MOCKS_PATH], + cliui: ['testing/test-setup/src/lib/cliui.mock.ts'], + fs: ['testing/test-setup/src/lib/fs.mock.ts'], + git: ['testing/test-setup/src/lib/git.mock.ts'], + portalClient: ['testing/test-setup/src/lib/portal-client.mock.ts'], + matcherPath: ['testing/test-setup/src/lib/extend/path.matcher.ts'], + chromePath: ['testing/test-setup/src/lib/chrome-path.mock.ts'], + }, + e2e: { + base: [RESET_MOCKS_PATH], + }, +} as const; + +export const createUnitConfig = ( + projectKey: string, + rest: ConfigRestParams, + overrides?: VitestOverrides, +) => { + const finalSetupFiles = overrides?.test?.setupFiles ?? [ + ...setupPresets.unit.base, + ]; + + return createVitestConfig( + { projectKey, kind: 'unit', ...rest }, + { ...overrides, test: { ...overrides?.test, setupFiles: finalSetupFiles } }, + ); +}; + +export const createIntConfig = ( + projectKey: string, + rest: ConfigRestParams, + overrides?: VitestOverrides, +) => { + const finalSetupFiles = overrides?.test?.setupFiles ?? [ + ...setupPresets.int.base, + ]; + + return createVitestConfig( + { projectKey, kind: 'int', ...rest }, + { ...overrides, test: { ...overrides?.test, setupFiles: finalSetupFiles } }, + ); +}; + +export const createE2eConfig = ( + projectKey: string, + rest: ConfigRestParams, + overrides?: VitestOverrides, +) => { + const finalSetupFiles = overrides?.test?.setupFiles ?? [ + ...setupPresets.e2e.base, + ]; + + return createVitestConfig( + { projectKey, kind: 'e2e', ...rest }, + { ...overrides, test: { ...overrides?.test, setupFiles: finalSetupFiles } }, + ); +}; diff --git a/testing/test-setup-config/src/lib/vitest-setup-presets.unit.test.ts b/testing/test-setup-config/src/lib/vitest-setup-presets.unit.test.ts new file mode 100644 index 000000000..cc303dd87 --- /dev/null +++ b/testing/test-setup-config/src/lib/vitest-setup-presets.unit.test.ts @@ -0,0 +1,667 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { VitestOverrides } from './vitest-config-factory.js'; +import * as configFactory from './vitest-config-factory.js'; +import { + createE2eConfig, + createIntConfig, + createUnitConfig, + setupPresets, +} from './vitest-setup-presets.js'; + +vi.mock('./vitest-config-factory.js', () => ({ + createVitestConfig: vi.fn().mockReturnValue('mocked-config'), +})); + +const MOCK_PROJECT_KEY = 'test-package'; +const MOCK_CONFIG_REST_PARAMS = { + projectRoot: '/test/project', + cacheKey: 'test-cache', +}; + +const TEST_TIMEOUTS = { + SHORT: 5000, + MEDIUM: 10_000, + LONG: 15_000, + E2E: 60_000, +} as const; + +describe('setupPresets', () => { + it('should export correct unit setup presets', () => { + expect(setupPresets.unit).toEqual({ + base: [ + 'testing/test-setup/src/lib/console.mock.ts', + 'testing/test-setup/src/lib/reset.mocks.ts', + 'testing/test-setup/src/lib/cliui.mock.ts', + 'testing/test-setup/src/lib/fs.mock.ts', + 'testing/test-setup/src/lib/extend/ui-logger.matcher.ts', + ], + git: ['testing/test-setup/src/lib/git.mock.ts'], + portalClient: ['testing/test-setup/src/lib/portal-client.mock.ts'], + matchersCore: [ + 'testing/test-setup/src/lib/extend/markdown-table.matcher.ts', + 'testing/test-setup/src/lib/extend/jest-extended.matcher.ts', + ], + matcherPath: ['testing/test-setup/src/lib/extend/path.matcher.ts'], + }); + }); + + it('should export correct integration setup presets', () => { + expect(setupPresets.int).toEqual({ + base: [ + 'testing/test-setup/src/lib/console.mock.ts', + 'testing/test-setup/src/lib/reset.mocks.ts', + ], + cliui: ['testing/test-setup/src/lib/cliui.mock.ts'], + fs: ['testing/test-setup/src/lib/fs.mock.ts'], + git: ['testing/test-setup/src/lib/git.mock.ts'], + portalClient: ['testing/test-setup/src/lib/portal-client.mock.ts'], + matcherPath: ['testing/test-setup/src/lib/extend/path.matcher.ts'], + chromePath: ['testing/test-setup/src/lib/chrome-path.mock.ts'], + }); + }); + + it('should export correct e2e setup presets', () => { + expect(setupPresets.e2e).toEqual({ + base: ['testing/test-setup/src/lib/reset.mocks.ts'], + }); + }); + + it('should be defined as a const object', () => { + expect(setupPresets).toBeDefined(); + expect(typeof setupPresets).toBe('object'); + }); +}); + +describe('createUnitConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call createVitestConfig with correct parameters and default setupFiles', () => { + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.unit.base, + }, + }, + ); + }); + + it('should use custom setupFiles from overrides when provided', () => { + const customSetupFiles = ['unit-setup1.ts', 'unit-setup2.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + }, + }; + + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + }, + }, + ); + }); + + it('should merge other overrides correctly while using default setupFiles', () => { + const overrides: VitestOverrides = { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + }, + build: { + target: 'es2020', + }, + }; + + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + setupFiles: setupPresets.unit.base, + }, + build: { + target: 'es2020', + }, + }, + ); + }); + + it('should handle overrides with custom setupFiles and other test options', () => { + const customSetupFiles = ['unit-custom.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom' as any, + }, + }; + + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom', + }, + }, + ); + }); + + it('should handle undefined overrides', () => { + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, undefined); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.unit.base, + }, + }, + ); + }); + + it('should handle overrides without test config', () => { + const overrides: VitestOverrides = { + build: { + target: 'es2020', + }, + }; + + createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + build: { + target: 'es2020', + }, + test: { + setupFiles: setupPresets.unit.base, + }, + }, + ); + }); + + it('should return the result from createVitestConfig', () => { + const result = createUnitConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(result).toBe('mocked-config'); + }); + + it('should handle empty projectKey gracefully', () => { + const result = createUnitConfig('', MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: '', + kind: 'unit', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.unit.base, + }, + }, + ); + expect(result).toBe('mocked-config'); + }); +}); + +describe('createIntConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call createVitestConfig with correct parameters and default setupFiles', () => { + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.int.base, + }, + }, + ); + }); + + it('should use custom setupFiles from overrides when provided', () => { + const customSetupFiles = ['int-setup1.ts', 'int-setup2.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + }, + }; + + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + }, + }, + ); + }); + + it('should merge other overrides correctly while using default setupFiles', () => { + const overrides: VitestOverrides = { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + }, + build: { + target: 'es2020', + }, + }; + + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + setupFiles: setupPresets.int.base, + }, + build: { + target: 'es2020', + }, + }, + ); + }); + + it('should handle overrides with custom setupFiles and other test options', () => { + const customSetupFiles = ['int-custom.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom' as any, + }, + }; + + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom', + }, + }, + ); + }); + + it('should handle undefined overrides', () => { + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, undefined); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.int.base, + }, + }, + ); + }); + + it('should handle overrides without test config', () => { + const overrides: VitestOverrides = { + build: { + target: 'es2020', + }, + }; + + createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + build: { + target: 'es2020', + }, + test: { + setupFiles: setupPresets.int.base, + }, + }, + ); + }); + + it('should return the result from createVitestConfig', () => { + const result = createIntConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(result).toBe('mocked-config'); + }); + + it('should handle empty projectKey gracefully', () => { + const result = createIntConfig('', MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: '', + kind: 'int', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.int.base, + }, + }, + ); + expect(result).toBe('mocked-config'); + }); +}); + +describe('createE2eConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call createVitestConfig with correct parameters and default setupFiles', () => { + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.e2e.base, + }, + }, + ); + }); + + it('should use custom setupFiles from overrides when provided', () => { + const customSetupFiles = ['e2e-setup1.ts', 'e2e-setup2.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + }, + }; + + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + }, + }, + ); + }); + + it('should merge other overrides correctly while using default setupFiles', () => { + const overrides: VitestOverrides = { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + }, + build: { + target: 'es2020', + }, + }; + + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + testTimeout: TEST_TIMEOUTS.MEDIUM, + globals: false, + setupFiles: setupPresets.e2e.base, + }, + build: { + target: 'es2020', + }, + }, + ); + }); + + it('should handle overrides with custom setupFiles and other test options', () => { + const customSetupFiles = ['e2e-custom.ts']; + const overrides: VitestOverrides = { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom' as any, + }, + }; + + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: customSetupFiles, + testTimeout: TEST_TIMEOUTS.SHORT, + environment: 'jsdom', + }, + }, + ); + }); + + it('should handle undefined overrides', () => { + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, undefined); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.e2e.base, + }, + }, + ); + }); + + it('should handle overrides without test config', () => { + const overrides: VitestOverrides = { + build: { + target: 'es2020', + }, + }; + + createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS, overrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: MOCK_PROJECT_KEY, + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + build: { + target: 'es2020', + }, + test: { + setupFiles: setupPresets.e2e.base, + }, + }, + ); + }); + + it('should return the result from createVitestConfig', () => { + const result = createE2eConfig(MOCK_PROJECT_KEY, MOCK_CONFIG_REST_PARAMS); + + expect(result).toBe('mocked-config'); + }); + + it('should handle empty projectKey gracefully', () => { + const result = createE2eConfig('', MOCK_CONFIG_REST_PARAMS); + + expect(configFactory.createVitestConfig).toHaveBeenCalledWith( + { + projectKey: '', + kind: 'e2e', + ...MOCK_CONFIG_REST_PARAMS, + }, + { + test: { + setupFiles: setupPresets.e2e.base, + }, + }, + ); + expect(result).toBe('mocked-config'); + }); +}); + +describe('integration between preset functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should use different setup presets for different test kinds', () => { + createUnitConfig('test-pkg', { projectRoot: '/test' }); + createIntConfig('test-pkg', { projectRoot: '/test' }); + createE2eConfig('test-pkg', { projectRoot: '/test' }); + + expect(configFactory.createVitestConfig).toHaveBeenCalledTimes(3); + + expect(configFactory.createVitestConfig).toHaveBeenNthCalledWith( + 1, + { projectKey: 'test-pkg', kind: 'unit', projectRoot: '/test' }, + { test: { setupFiles: setupPresets.unit.base } }, + ); + + expect(configFactory.createVitestConfig).toHaveBeenNthCalledWith( + 2, + { projectKey: 'test-pkg', kind: 'int', projectRoot: '/test' }, + { test: { setupFiles: setupPresets.int.base } }, + ); + + expect(configFactory.createVitestConfig).toHaveBeenNthCalledWith( + 3, + { projectKey: 'test-pkg', kind: 'e2e', projectRoot: '/test' }, + { test: { setupFiles: setupPresets.e2e.base } }, + ); + }); + + it('should handle complex scenarios with all preset functions', () => { + const complexOverrides: VitestOverrides = { + test: { + setupFiles: ['global-setup.ts'], + testTimeout: TEST_TIMEOUTS.LONG, + coverage: { + enabled: true, + thresholds: { + global: { + statements: 90, + }, + }, + }, + }, + build: { + target: 'es2022', + }, + }; + + const restParams = { + projectRoot: '/complex/project', + cacheKey: 'complex-cache', + }; + + createUnitConfig('complex-unit', restParams, complexOverrides); + createIntConfig('complex-int', restParams, complexOverrides); + createE2eConfig('complex-e2e', restParams, complexOverrides); + + expect(configFactory.createVitestConfig).toHaveBeenCalledTimes(3); + + const calls = (configFactory.createVitestConfig as any).mock.calls; + calls.forEach((call: any) => { + expect(call[1].test.setupFiles).toStrictEqual(['global-setup.ts']); + }); + + expect(calls[0][0].kind).toBe('unit'); + expect(calls[1][0].kind).toBe('int'); + expect(calls[2][0].kind).toBe('e2e'); + }); +}); diff --git a/testing/test-setup-config/src/lib/vitest-tsconfig-path-aliases.ts b/testing/test-setup-config/src/lib/vitest-tsconfig-path-aliases.ts new file mode 100644 index 000000000..aec88e199 --- /dev/null +++ b/testing/test-setup-config/src/lib/vitest-tsconfig-path-aliases.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from 'tsconfig-paths'; +import type { Alias, AliasOptions } from 'vite'; + +export function tsconfigPathAliases(projectRootUrl?: URL): AliasOptions { + const tsconfigPath = projectRootUrl + ? path.resolve(fileURLToPath(projectRootUrl), 'tsconfig.base.json') + : 'tsconfig.base.json'; + const result = loadConfig(tsconfigPath); + if (result.resultType === 'failed') { + throw new Error( + `Failed to load path aliases from tsconfig for Vitest: ${result.message}`, + ); + } + return Object.entries(result.paths) + .map(([key, value]) => [key, value[0]]) + .filter((pair): pair is [string, string] => pair[1] != null) + .map( + ([importPath, relativePath]): Alias => ({ + find: importPath, + replacement: projectRootUrl + ? path.resolve(fileURLToPath(projectRootUrl), relativePath) + : new URL(`../${relativePath}`, import.meta.url).pathname, + }), + ); +} diff --git a/testing/test-setup-config/tsconfig.json b/testing/test-setup-config/tsconfig.json new file mode 100644 index 000000000..465306e46 --- /dev/null +++ b/testing/test-setup-config/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} diff --git a/testing/test-setup-config/tsconfig.lib.json b/testing/test-setup-config/tsconfig.lib.json new file mode 100644 index 000000000..3cc313086 --- /dev/null +++ b/testing/test-setup-config/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vitest.unit.config.ts", + "src/vitest.d.ts", + "src/**/*.unit.test.ts", + "src/**/*.int.test.ts" + ] +} diff --git a/testing/test-setup-config/tsconfig.test.json b/testing/test-setup-config/tsconfig.test.json new file mode 100644 index 000000000..5fddc20ae --- /dev/null +++ b/testing/test-setup-config/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + }, + "include": [ + "vitest.unit.config.ts", + "src/vitest.d.ts", + "src/**/*.unit.test.ts", + "src/**/*.d.ts", + "src/**/*.int.test.ts" + ] +} diff --git a/testing/test-setup-config/vitest.unit.config.ts b/testing/test-setup-config/vitest.unit.config.ts new file mode 100644 index 000000000..95bef2c28 --- /dev/null +++ b/testing/test-setup-config/vitest.unit.config.ts @@ -0,0 +1,23 @@ +/// +import { + createUnitConfig, + setupPresets, +} from './src/lib/vitest-setup-presets.js'; + +export default createUnitConfig( + 'test-setup-config', + { + projectRoot: new URL('../../', import.meta.url), + }, + { + test: { + include: ['src/**/*.{unit,type}.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: [...setupPresets.unit.base, ...setupPresets.unit.matcherPath], + coverage: { + enabled: true, + reporter: ['text', 'lcov'], + exclude: ['**/*.mock.{mjs,ts}', '**/*.config.{js,mjs,ts}'], + }, + }, + }, +); diff --git a/testing/test-setup/README.md b/testing/test-setup/README.md index db0ce1eba..3e9c2c236 100644 --- a/testing/test-setup/README.md +++ b/testing/test-setup/README.md @@ -4,6 +4,10 @@ This library contains test setup. More on this subject as well as all the testing strategy principles can be found on the GitHub [wiki](https://github.com/code-pushup/cli/wiki/Testing-Strategy#mocking). +## Shared config + +[README](./src/lib/config/README.md) how to use vitest config factory. + ## Mock setup In this library you can find all files that can be used in `setupFiles` property of `vitest.config.(unit|int|e2e).ts` files. Currently include: diff --git a/tools/vitest-tsconfig-path-aliases.ts b/tools/vitest-tsconfig-path-aliases.ts index ac8be04df..aec88e199 100644 --- a/tools/vitest-tsconfig-path-aliases.ts +++ b/tools/vitest-tsconfig-path-aliases.ts @@ -1,8 +1,13 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { loadConfig } from 'tsconfig-paths'; import type { Alias, AliasOptions } from 'vite'; -export function tsconfigPathAliases(): AliasOptions { - const result = loadConfig('tsconfig.base.json'); +export function tsconfigPathAliases(projectRootUrl?: URL): AliasOptions { + const tsconfigPath = projectRootUrl + ? path.resolve(fileURLToPath(projectRootUrl), 'tsconfig.base.json') + : 'tsconfig.base.json'; + const result = loadConfig(tsconfigPath); if (result.resultType === 'failed') { throw new Error( `Failed to load path aliases from tsconfig for Vitest: ${result.message}`, @@ -14,7 +19,9 @@ export function tsconfigPathAliases(): AliasOptions { .map( ([importPath, relativePath]): Alias => ({ find: importPath, - replacement: new URL(`../${relativePath}`, import.meta.url).pathname, + replacement: projectRootUrl + ? path.resolve(fileURLToPath(projectRootUrl), relativePath) + : new URL(`../${relativePath}`, import.meta.url).pathname, }), ); } diff --git a/tsconfig.base.json b/tsconfig.base.json index 7f3b4d21f..4bca1c69f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -39,6 +39,9 @@ "@code-pushup/nx-plugin": ["packages/nx-plugin/src/index.ts"], "@code-pushup/test-nx-utils": ["testing/test-nx-utils/src/index.ts"], "@code-pushup/test-setup": ["testing/test-setup/src/index.ts"], + "@code-pushup/test-setup-config": [ + "testing/test-setup-config/src/index.ts" + ], "@code-pushup/test-utils": ["testing/test-utils/src/index.ts"], "@code-pushup/typescript-plugin": [ "packages/plugin-typescript/src/index.ts"