diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/constants.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/constants.ts new file mode 100644 index 000000000000..dbf38fcbba7c --- /dev/null +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/constants.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export const SUPPORTED_REPORTERS = new Set([ + 'default', + 'verbose', + 'dots', + 'json', + 'junit', + 'tap', + 'tap-flat', + 'html', +]); + +export const SUPPORTED_COVERAGE_REPORTERS = new Set([ + 'html', + 'lcov', + 'lcovonly', + 'text', + 'text-summary', + 'cobertura', + 'json', + 'json-summary', +]); + +export const BUILD_OPTIONS_KEYS = [ + 'assets', + 'styles', + 'scripts', + 'polyfills', + 'inlineStyleLanguage', + 'stylePreprocessorOptions', + 'externalDependencies', + 'loader', + 'define', + 'fileReplacements', + 'webWorkerTsConfig', + 'aot', +]; diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-processor.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-processor.ts new file mode 100644 index 000000000000..9fc773470c52 --- /dev/null +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-processor.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { json } from '@angular-devkit/core'; +import { SchematicContext, Tree } from '@angular-devkit/schematics'; +import { KarmaConfigAnalysis, analyzeKarmaConfig } from '../karma/karma-config-analyzer'; +import { compareKarmaConfigToDefault, hasDifferences } from '../karma/karma-config-comparer'; +import { SUPPORTED_COVERAGE_REPORTERS, SUPPORTED_REPORTERS } from './constants'; + +function extractReporters( + analysis: KarmaConfigAnalysis, + options: Record, + projectName: string, + context: SchematicContext, +): void { + const reporters = analysis.settings.get('reporters'); + if (Array.isArray(reporters)) { + const mappedReporters: string[] = []; + for (const r of reporters) { + if (typeof r === 'string') { + if (r === 'progress') { + mappedReporters.push('default'); + } else if (r === 'kjhtml') { + context.logger.warn( + `Project "${projectName}" uses the "kjhtml" reporter. ` + + `This has not been automatically mapped. ` + + `For an interactive test UI in Vitest, consider setting the "ui" option to true in your test target options ` + + `and installing "@vitest/ui".`, + ); + } else if (SUPPORTED_REPORTERS.has(r)) { + mappedReporters.push(r); + } else { + context.logger.warn( + `Project "${projectName}" uses a custom Karma reporter "${r}". ` + + `This reporter cannot be automatically mapped to Vitest. ` + + `Please check the Vitest documentation for equivalent reporters.`, + ); + } + } else { + context.logger.warn( + `Project "${projectName}" has a non-string reporter in Karma config. ` + + `This cannot be automatically mapped to Vitest.`, + ); + } + } + if (mappedReporters.length > 0) { + options['reporters'] = [...new Set(mappedReporters)]; + } + } +} + +function extractCoverageSettings( + analysis: KarmaConfigAnalysis, + options: Record, + projectName: string, + context: SchematicContext, +): void { + const coverageReporter = analysis.settings.get('coverageReporter'); + if (typeof coverageReporter !== 'object' || coverageReporter === null) { + return; + } + + // Extract coverage reporters + const covReporters = (coverageReporter as Record)['reporters']; + if (Array.isArray(covReporters)) { + const mappedCovReporters: string[] = []; + for (const r of covReporters) { + let type: string | undefined; + if (typeof r === 'object' && r !== null && 'type' in r) { + if (typeof r['type'] === 'string') { + type = r['type']; + } + } else if (typeof r === 'string') { + type = r; + } + + if (type) { + if (SUPPORTED_COVERAGE_REPORTERS.has(type)) { + mappedCovReporters.push(type); + } else { + context.logger.warn( + `Project "${projectName}" uses a custom coverage reporter "${type}". ` + + `This reporter cannot be automatically mapped to Vitest. ` + + `Please check the Vitest documentation for equivalent coverage reporters.`, + ); + } + } + } + if (mappedCovReporters.length > 0) { + options['coverageReporters'] = [...new Set(mappedCovReporters)]; + } + } + + // Extract coverage thresholds + const check = (coverageReporter as Record)['check']; + if (typeof check === 'object' && check !== null) { + const global = (check as Record)['global']; + if (typeof global === 'object' && global !== null) { + const thresholds: Record = {}; + const keys = ['statements', 'branches', 'functions', 'lines']; + for (const key of keys) { + const value = (global as Record)[key]; + if (typeof value === 'number') { + thresholds[key] = value; + } + } + if (Object.keys(thresholds).length > 0) { + options['coverageThresholds'] = { + ...thresholds, + perFile: false, + }; + } + } + } +} + +export async function processKarmaConfig( + karmaConfig: string, + options: Record, + projectName: string, + context: SchematicContext, + tree: Tree, + removableKarmaConfigs: Map, + needDevkitPlugin: boolean, + manualMigrationFiles: string[], +): Promise { + if (tree.exists(karmaConfig)) { + const content = tree.readText(karmaConfig); + const analysis = analyzeKarmaConfig(content); + + extractReporters(analysis, options, projectName, context); + extractCoverageSettings(analysis, options, projectName, context); + + let isRemovable = removableKarmaConfigs.get(karmaConfig); + if (isRemovable === undefined) { + if (analysis.hasUnsupportedValues) { + isRemovable = false; + } else { + const diff = await compareKarmaConfigToDefault( + analysis, + projectName, + karmaConfig, + needDevkitPlugin, + ); + isRemovable = !hasDifferences(diff) && diff.isReliable; + } + removableKarmaConfigs.set(karmaConfig, isRemovable); + } + + if (isRemovable) { + tree.delete(karmaConfig); + } else { + context.logger.warn( + `Project "${projectName}" uses a custom Karma configuration file "${karmaConfig}". ` + + `Tests have been migrated to use Vitest, but you may need to manually migrate custom settings ` + + `from this Karma config to a Vitest config (e.g. vitest.config.ts).`, + ); + manualMigrationFiles.push(karmaConfig); + } + } + delete options['karmaConfig']; +} diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts new file mode 100644 index 000000000000..b248f1771247 --- /dev/null +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { json } from '@angular-devkit/core'; +import { Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics'; +import { isDeepStrictEqual } from 'util'; +import { DependencyType, ExistingBehavior, addDependency } from '../../utility/dependency'; +import { latestVersions } from '../../utility/latest-versions'; +import { TargetDefinition, allTargetOptions, updateWorkspace } from '../../utility/workspace'; +import { Builders } from '../../utility/workspace-models'; +import { BUILD_OPTIONS_KEYS } from './constants'; +import { processKarmaConfig } from './karma-processor'; + +async function processTestTargetOptions( + testTarget: TargetDefinition, + projectName: string, + context: SchematicContext, + tree: Tree, + removableKarmaConfigs: Map, + customBuildOptions: Record>, + needDevkitPlugin: boolean, + manualMigrationFiles: string[], +): Promise { + let needsCoverage = false; + for (const [configName, options] of allTargetOptions(testTarget, false)) { + const configKey = configName || ''; + if (!customBuildOptions[configKey]) { + // Match Karma behavior where AOT was disabled by default + customBuildOptions[configKey] = { + aot: false, + optimization: false, + extractLicenses: false, + }; + } + + // Collect custom build options + for (const key of BUILD_OPTIONS_KEYS) { + if (options[key] !== undefined) { + customBuildOptions[configKey][key] = options[key]; + delete options[key]; + } + } + + // Map Karma options to Unit-Test options + if (options['codeCoverage'] !== undefined) { + options['coverage'] = options['codeCoverage']; + delete options['codeCoverage']; + } + + if (options['codeCoverageExclude'] !== undefined) { + options['coverageExclude'] = options['codeCoverageExclude']; + delete options['codeCoverageExclude']; + } + + if (options['coverage'] === true || options['coverageExclude'] !== undefined) { + needsCoverage = true; + } + + if (options['sourceMap'] !== undefined) { + context.logger.info( + `Project "${projectName}" has "sourceMap" set for tests. ` + + `In unit-test builder with Vitest, source maps are always enabled. The option has been removed.`, + ); + delete options['sourceMap']; + } + + // Convert browser list to array format if it is a comma-separated string + const browsers = options['browsers']; + if (typeof browsers === 'string') { + options['browsers'] = browsers.split(',').map((b) => b.trim()); + } else if (browsers === false) { + options['browsers'] = []; + } + + const updatedBrowsers = options['browsers']; + if (Array.isArray(updatedBrowsers) && updatedBrowsers.length > 0) { + context.logger.info( + `Project "${projectName}" has browsers configured for tests. ` + + `To run tests in a browser with Vitest, you will need to install either ` + + `"@vitest/browser-playwright" or "@vitest/browser-webdriverio" depending on your preference.`, + ); + } + + // Check if the karma configuration file can be safely removed and extract settings + const karmaConfig = options['karmaConfig']; + if (typeof karmaConfig === 'string') { + await processKarmaConfig( + karmaConfig, + options, + projectName, + context, + tree, + removableKarmaConfigs, + needDevkitPlugin, + manualMigrationFiles, + ); + } + + // Map the main entry file to the setupFiles of the unit-test builder + const mainFile = options['main']; + if (typeof mainFile === 'string') { + options['setupFiles'] = [mainFile]; + + context.logger.info( + `Project "${projectName}" uses a "main" entry file for tests: "${mainFile}". ` + + `This has been mapped to the unit-test builder "setupFiles" array. ` + + `Please ensure you remove any TestBed.initTestEnvironment calls from this file ` + + `as the builder now handles test environment initialization automatically.`, + ); + } + delete options['main']; + } + + return needsCoverage; +} + +function updateProjects(tree: Tree, context: SchematicContext): Rule { + return updateWorkspace(async (workspace) => { + let hasMigratedAny = false; + let needsCoverage = false; + const removableKarmaConfigs = new Map(); + const migratedProjects: string[] = []; + const skippedNonApplications: string[] = []; + const skippedMissingAppBuilder: string[] = []; + const manualMigrationFiles: string[] = []; + + for (const [projectName, project] of workspace.projects) { + // Restrict to application types for now + if (project.extensions.projectType !== 'application') { + skippedNonApplications.push(projectName); + continue; + } + + // Check if build target uses the new application builder + const buildTarget = project.targets.get('build'); + if (!buildTarget || buildTarget.builder !== '@angular/build:application') { + context.logger.info( + `Project "${projectName}" cannot be migrated to Vitest yet. ` + + `The project must first be migrated to use the "@angular/build:application" builder.`, + ); + skippedMissingAppBuilder.push(projectName); + continue; + } + + // Find the test target to migrate + const testTarget = project.targets.get('test'); + if (!testTarget) { + continue; + } + + let isKarma = false; + let needDevkitPlugin = false; + // Check if target uses Karma builders + switch (testTarget.builder) { + case Builders.Karma: + isKarma = true; + needDevkitPlugin = true; + break; + case Builders.BuildKarma: + isKarma = true; + break; + } + + if (!isKarma) { + continue; + } + + // Store custom build options to move to a new build configuration if needed + const customBuildOptions: Record> = {}; + + const projectNeedsCoverage = await processTestTargetOptions( + testTarget, + projectName, + context, + tree, + removableKarmaConfigs, + customBuildOptions, + needDevkitPlugin, + manualMigrationFiles, + ); + + if (projectNeedsCoverage) { + needsCoverage = true; + } + + // If we have custom build options, create testing configurations + const baseOptions = buildTarget.options || {}; + + for (const [configKey, configOptions] of Object.entries(customBuildOptions)) { + const finalConfig: Record = {}; + + // Omit options that already have the same value in the base build options. + // Using isDeepStrictEqual for a deep comparison of arrays and objects. + for (const [key, value] of Object.entries(configOptions)) { + if (!isDeepStrictEqual(value, baseOptions[key])) { + finalConfig[key] = value; + } + } + + if (Object.keys(finalConfig).length > 0) { + buildTarget.configurations ??= {}; + const configurations = buildTarget.configurations; + + let configName = configKey ? `testing-${configKey}` : 'testing'; + if (configurations[configName]) { + let counter = 1; + while (configurations[`${configName}-${counter}`]) { + counter++; + } + configName = `${configName}-${counter}`; + } + + configurations[configName] = finalConfig; + + if (configKey === '') { + testTarget.options ??= {}; + testTarget.options['buildTarget'] = `:build:${configName}`; + } else { + testTarget.configurations ??= {}; + testTarget.configurations[configKey] ??= {}; + testTarget.configurations[configKey]['buildTarget'] = `:build:${configName}`; + } + } + } + + // Update builder + testTarget.builder = '@angular/build:unit-test'; + testTarget.options ??= {}; + testTarget.options['runner'] = 'vitest'; + + hasMigratedAny = true; + migratedProjects.push(projectName); + } + + // Log summary + context.logger.info('\n--- Karma to Vitest Migration Summary ---'); + context.logger.info(`Projects migrated: ${migratedProjects.length}`); + if (migratedProjects.length > 0) { + context.logger.info(` - ${migratedProjects.join(', ')}`); + } + context.logger.info(`Projects skipped (non-applications): ${skippedNonApplications.length}`); + if (skippedNonApplications.length > 0) { + context.logger.info(` - ${skippedNonApplications.join(', ')}`); + } + context.logger.info( + `Projects skipped (missing application builder): ${skippedMissingAppBuilder.length}`, + ); + if (skippedMissingAppBuilder.length > 0) { + context.logger.info(` - ${skippedMissingAppBuilder.join(', ')}`); + } + + const uniqueManualFiles = [...new Set(manualMigrationFiles)]; + if (uniqueManualFiles.length > 0) { + context.logger.warn(`\nThe following Karma configuration files require manual migration:`); + for (const file of uniqueManualFiles) { + context.logger.warn(` - ${file}`); + } + } + context.logger.info('-----------------------------------------\n'); + + if (hasMigratedAny) { + const rules = [ + addDependency('vitest', latestVersions['vitest'], { + type: DependencyType.Dev, + existing: ExistingBehavior.Skip, + }), + ]; + + if (needsCoverage) { + rules.push( + addDependency('@vitest/coverage-v8', latestVersions['@vitest/coverage-v8'], { + type: DependencyType.Dev, + existing: ExistingBehavior.Skip, + }), + ); + } + + return chain(rules); + } + }); +} + +export default function (): Rule { + return updateProjects; +} diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts new file mode 100644 index 000000000000..e206002ab665 --- /dev/null +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { latestVersions } from '../../utility/latest-versions'; + +describe('Migration from Karma to Vitest unit-test builder', () => { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + tree.create( + '/package.json', + JSON.stringify({ + devDependencies: { + '@angular/build': latestVersions.AngularBuild, + }, + }), + ); + tree.create( + '/angular.json', + JSON.stringify({ + version: 1, + projects: { + app: { + root: '', + sourceRoot: 'src', + projectType: 'application', + targets: { + build: { + builder: '@angular/build:application', + options: { + tsConfig: 'tsconfig.app.json', + }, + }, + test: { + builder: '@angular/build:karma', + options: { + tsConfig: 'tsconfig.spec.json', + karmaConfig: 'karma.conf.js', + }, + }, + }, + }, + }, + }), + ); + }); + + it('should migrate standard karma builder to unit-test builder', async () => { + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects } = newTree.readJson('/angular.json') as any; + + expect(projects.app.targets.test.builder).toBe('@angular/build:unit-test'); + expect(projects.app.targets.test.options.runner).toBe('vitest'); + expect(projects.app.targets.test.options.karmaConfig).toBeUndefined(); + }); + + it('should map codeCoverage to coverage', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.options.codeCoverage = true; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.test.options.coverage).toBeTrue(); + expect(newProjects.app.targets.test.options.codeCoverage).toBeUndefined(); + }); + + it('should map browsers string to array', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.options.browsers = 'Chrome,Firefox'; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.test.options.browsers).toEqual(['Chrome', 'Firefox']); + }); + + it('should move custom build options to testing configuration', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.options.assets = ['src/test.assets']; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.build.configurations.testing.assets).toEqual([ + 'src/test.assets', + ]); + expect(newProjects.app.targets.build.configurations.testing.aot).toBeFalse(); + expect(newProjects.app.targets.build.configurations.testing.optimization).toBeFalse(); + expect(newProjects.app.targets.build.configurations.testing.extractLicenses).toBeFalse(); + expect(newProjects.app.targets.test.options.assets).toBeUndefined(); + expect(newProjects.app.targets.test.options.buildTarget).toBe(':build:testing'); + }); + + it('should move custom build options per configuration to testing configurations', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.configurations = { + ci: { + assets: ['src/ci.assets'], + }, + }; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.build.configurations['testing-ci'].assets).toEqual([ + 'src/ci.assets', + ]); + expect(newProjects.app.targets.test.configurations.ci.buildTarget).toBe(':build:testing-ci'); + expect(newProjects.app.targets.test.configurations.ci.assets).toBeUndefined(); + }); + + it('should omit testing configuration if options match base build options', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.build.options.aot = false; + projects.app.targets.build.options.optimization = false; + projects.app.targets.build.options.extractLicenses = false; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.build.configurations?.testing).toBeUndefined(); + expect(newProjects.app.targets.test.options.buildTarget).toBeUndefined(); + }); + + it('should skip migration for library projects', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.projectType = 'library'; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.test.builder).toBe('@angular/build:karma'); + }); + + it('should skip migration if build target is not @angular/build:application', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.build.builder = '@angular-devkit/build-angular:browser'; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.test.builder).toBe('@angular/build:karma'); + }); + + it('should map reporters from karma config', async () => { + tree.create( + 'karma.conf.js', + ` +module.exports = function (config) { + config.set({ + reporters: ['progress', 'dots', 'kjhtml', 'custom'], + }); +}; +`, + ); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects } = newTree.readJson('/angular.json') as any; + + expect(projects.app.targets.test.options.reporters).toEqual(['default', 'dots']); + }); + + it('should map coverage reporters from karma config', async () => { + tree.create( + 'karma.conf.js', + ` +module.exports = function (config) { + config.set({ + coverageReporter: { + reporters: [ + { type: 'html' }, + { type: 'text-summary' }, + 'lcov' + ] + }, + }); +}; +`, + ); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects } = newTree.readJson('/angular.json') as any; + + expect(projects.app.targets.test.options.coverageReporters).toEqual([ + 'html', + 'text-summary', + 'lcov', + ]); + }); + + it('should map coverage thresholds from karma config', async () => { + tree.create( + 'karma.conf.js', + ` +module.exports = function (config) { + config.set({ + coverageReporter: { + check: { + global: { + statements: 80, + branches: 70, + functions: 60, + lines: 50 + } + } + }, + }); +}; +`, + ); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects } = newTree.readJson('/angular.json') as any; + + expect(projects.app.targets.test.options.coverageThresholds).toEqual({ + statements: 80, + branches: 70, + functions: 60, + lines: 50, + perFile: false, + }); + }); + + it('should restrict and deduplicate coverage reporters from karma config', async () => { + tree.create( + 'karma.conf.js', + ` +module.exports = function (config) { + config.set({ + coverageReporter: { + reporters: [ + { type: 'html' }, + { type: 'text-summary' }, + { type: 'custom' }, + 'html', + 'lcov' + ] + }, + }); +}; +`, + ); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects } = newTree.readJson('/angular.json') as any; + + expect(projects.app.targets.test.options.coverageReporters).toEqual([ + 'html', + 'text-summary', + 'lcov', + ]); + }); + + it('should add vitest dependency', async () => { + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { devDependencies } = newTree.readJson('/package.json') as any; + + expect(devDependencies.vitest).toBe(latestVersions['vitest']); + }); + + it('should delete default karma.conf.js', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.builder = '@angular-devkit/build-angular:karma'; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const DEFAULT_KARMA_CONFIG = ` +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + jasmine: {}, + }, + jasmineHtmlReporter: { + suppressAll: true + }, + coverageReporter: { + dir: require('path').join(__dirname, './coverage/app'), + subdir: '.', + reporters: [ + { type: 'html' }, + { type: 'text-summary' } + ] + }, + reporters: ['progress', 'kjhtml'], + browsers: ['Chrome'], + restartOnFileChange: true + }); +}; +`; + tree.create('karma.conf.js', DEFAULT_KARMA_CONFIG); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + expect(newTree.exists('karma.conf.js')).toBeFalse(); + }); + it('should shift main compilation entry file directly into setupFiles array', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.options.main = 'src/test.ts'; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.test.options.setupFiles).toEqual(['src/test.ts']); + expect(newProjects.app.targets.test.options.main).toBeUndefined(); + }); + it('should generate unique testing configuration name preventing collision overwrites', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.build.configurations = { + testing: { assets: [] }, + 'testing-1': { assets: [] }, + }; + projects.app.targets.test.options.assets = ['src/test.assets']; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + expect(newProjects.app.targets.build.configurations['testing-2'].assets).toEqual([ + 'src/test.assets', + ]); + expect(newProjects.app.targets.test.options.buildTarget).toBe(':build:testing-2'); + }); + it('should inject @vitest/coverage-v8 whenever coverage presence is detected', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.options.codeCoverage = true; + tree.overwrite('/angular.json', JSON.stringify({ version: 1, projects })); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const { devDependencies } = newTree.readJson('/package.json') as any; + + expect(devDependencies['@vitest/coverage-v8']).toBe(latestVersions['@vitest/coverage-v8']); + }); +}); diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 3745792eb6cc..536b2b1ae3b1 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -14,6 +14,12 @@ "factory": "./karma/migration", "description": "Remove any karma configuration files that only contain the default content. The default configuration is automatically available without a specific project file." }, + "migrate-karma-to-vitest": { + "version": "22.0.0", + "factory": "./migrate-karma-to-vitest/migration", + "description": "Migrate projects using legacy Karma unit-test builder to the new unit-test builder with Vitest.", + "optional": true + }, "update-workspace-config": { "version": "22.0.0", "factory": "./update-workspace-config/migration", diff --git a/packages/schematics/angular/utility/latest-versions/package.json b/packages/schematics/angular/utility/latest-versions/package.json index a696fc09273c..1994906687fe 100644 --- a/packages/schematics/angular/utility/latest-versions/package.json +++ b/packages/schematics/angular/utility/latest-versions/package.json @@ -25,6 +25,7 @@ "tslib": "^2.3.0", "typescript": "~6.0.2", "vitest": "^4.0.8", + "@vitest/coverage-v8": "^4.0.8", "@vitest/browser-playwright": "^4.0.8", "@vitest/browser-webdriverio": "^4.0.8", "@vitest/browser-preview": "^4.0.8",