From 4dbaed602194295d66b8477343322352bd8dc602 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:13:41 -0400 Subject: [PATCH] feat(@schematics/angular): add migrate-karma-to-vitest update migration Migrate projects using the Karma unit-test builder to the new @angular/build:unit-test builder with Vitest. This migration automatically updates target builders, translates builder options (such as coverage and browsers format), moves custom build options to dedicated configurations to prevent loss of custom settings, cleans up default configuration files, and installs required Vitest runner packages. --- .../migrate-karma-to-vitest/migration.ts | 481 ++++++++++++++++++ .../migrate-karma-to-vitest/migration_spec.ts | 363 +++++++++++++ .../migrations/migration-collection.json | 6 + .../utility/latest-versions/package.json | 1 + 4 files changed, 851 insertions(+) create mode 100644 packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts create mode 100644 packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts 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..4895c32645b2 --- /dev/null +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -0,0 +1,481 @@ +/** + * @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 { KarmaConfigAnalysis, analyzeKarmaConfig } from '../karma/karma-config-analyzer'; +import { compareKarmaConfigToDefault, hasDifferences } from '../karma/karma-config-comparer'; + +const SUPPORTED_REPORTERS = new Set([ + 'default', + 'verbose', + 'dots', + 'json', + 'junit', + 'tap', + 'tap-flat', + 'html', +]); + +const SUPPORTED_COVERAGE_REPORTERS = new Set([ + 'html', + 'lcov', + 'lcovonly', + 'text', + 'text-summary', + 'cobertura', + 'json', + 'json-summary', +]); + +const BUILD_OPTIONS_KEYS = [ + 'assets', + 'styles', + 'scripts', + 'polyfills', + 'inlineStyleLanguage', + 'stylePreprocessorOptions', + 'externalDependencies', + 'loader', + 'define', + 'fileReplacements', + 'webWorkerTsConfig', + 'aot', +]; + +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, + }; + } + } + } +} + +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']; +} + +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",