From edfa782d52fd971aebead8b96b6ca470a3f5123e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:18:47 -0400 Subject: [PATCH 1/7] fix(@angular/build): use dynamic TestComponentRenderer for Vitest This commit implements a custom TestComponentRenderer in the virtual init-testbed.js file generated for Vitest. In Vitests non-isolated mode (isolate: false) with JSDOM, Vitest creates a fresh document for each spec file but reuses the module cache. The default Angular DOMTestComponentRenderer caches the document during initialization, leading to stale references and errors like setAttribute is not a function in subsequent tests. The new DynamicDOMTestComponentRenderer looks up the document dynamically on every operation, resolving the issue without requiring a breaking change to defaults or affecting browser-based testing. --- .../unit-test/runners/vitest/build-options.ts | 32 ++++++++++++++++++- tests/e2e/tests/vitest/larger-project.ts | 3 +- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 482878c520ca..34d65d3d418a 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -54,11 +54,15 @@ function createTestBedInitVirtualFile( }`; } + // The DynamicDOMTestComponentRenderer is used to avoid stale document references + // when running Vitest in non-isolated mode with JSDOM. It looks up the + // document dynamically on every operation instead of caching it. return ` // Initialize the Angular testing environment import { NgModule, provideZoneChangeDetection } from '@angular/core'; - import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing'; + import { getTestBed, ɵgetCleanupHook as getCleanupHook, TestComponentRenderer } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; + import { ɵgetDOM } from '@angular/common'; import { afterEach, beforeEach } from 'vitest'; ${providersImport} @@ -70,6 +74,31 @@ function createTestBedInitVirtualFile( beforeEach(getCleanupHook(false)); afterEach(getCleanupHook(true)); + class DynamicDOMTestComponentRenderer extends TestComponentRenderer { + insertRootElement(rootElId, tagName = 'div') { + this.removeAllRootElements(); + + const dom = ɵgetDOM(); + const doc = dom.getDefaultDocument(); + if (doc && doc.body) { + const rootElement = doc.createElement(tagName); + rootElement.setAttribute('id', rootElId); + doc.body.appendChild(rootElement); + } + } + + removeAllRootElements() { + const dom = ɵgetDOM(); + const doc = dom.getDefaultDocument(); + if (doc && typeof doc.querySelectorAll === 'function') { + const oldRoots = doc.querySelectorAll('[id^=root]'); + for (let i = 0; i < oldRoots.length; i++) { + dom.remove(oldRoots[i]); + } + } + } + } + const ANGULAR_TESTBED_SETUP = Symbol.for('@angular/cli/testbed-setup'); if (!globalThis[ANGULAR_TESTBED_SETUP]) { globalThis[ANGULAR_TESTBED_SETUP] = true; @@ -82,6 +111,7 @@ function createTestBedInitVirtualFile( providers: [ ...(typeof Zone !== 'undefined' ? [provideZoneChangeDetection()] : []), ...providers, + { provide: TestComponentRenderer, useClass: DynamicDOMTestComponentRenderer }, ], }) class TestModule {} diff --git a/tests/e2e/tests/vitest/larger-project.ts b/tests/e2e/tests/vitest/larger-project.ts index 61b18b102c4b..90bb283f2d8a 100644 --- a/tests/e2e/tests/vitest/larger-project.ts +++ b/tests/e2e/tests/vitest/larger-project.ts @@ -2,12 +2,11 @@ import { ng } from '../../utils/process'; import { applyVitestBuilder } from '../../utils/vitest'; import assert from 'node:assert'; import { installPackage } from '../../utils/packages'; -import { exec } from '../../utils/process'; export default async function () { await applyVitestBuilder(); - const artifactCount = 100; + const artifactCount = 500; // A new project starts with 1 test file (app.spec.ts) // Each generated artifact will add one more test file. const initialTestCount = 1; From aed407db8be6bc7591fb82f10c79586cbd072a8a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:05:45 -0400 Subject: [PATCH 2/7] fix(@schematics/angular): defer karma config deletion in Karma to Vitest migration This commit resolves a race condition in the Karma-to-Vitest migration where shared configuration files were deleted prematurely within the schematic transaction. Previously, the routine erased the target file upon discovering it was identical to boilerplate, which blinded other referring projects to extraction metadata. The solution introduces analysis object caching so AST parsing occurs exactly once per discrete path. Deletion operations are deferred and batch-executed at the end of workspace processing. This mechanism guarantees continuous readability across iterating dependencies and yields operational speed improvements via memoized checks. --- .../karma-processor.ts | 50 +++++++++------- .../migrate-karma-to-vitest/migration.ts | 13 +++- .../migrate-karma-to-vitest/migration_spec.ts | 60 +++++++++++++++++++ 3 files changed, 98 insertions(+), 25 deletions(-) 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 index e5a83f4f2786..43e64eb2033a 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-processor.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-processor.ts @@ -119,42 +119,47 @@ function extractCoverageSettings( } } +export interface KarmaConfigProcessingResult { + analysis: KarmaConfigAnalysis; + isRemovable: boolean; +} + export async function processKarmaConfig( karmaConfig: string, options: Record, projectName: string, context: SchematicContext, tree: Tree, - removableKarmaConfigs: Map, + cache: Map, needDevkitPlugin: boolean, manualMigrationFiles: string[], ): Promise { - if (tree.exists(karmaConfig)) { + let cachedResult = cache.get(karmaConfig); + + if (!cachedResult && 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); + let isRemovable = false; + if (!analysis.hasUnsupportedValues) { + const diff = await compareKarmaConfigToDefault( + analysis, + projectName, + karmaConfig, + needDevkitPlugin, + ); + isRemovable = !hasDifferences(diff) && diff.isReliable; } - if (isRemovable) { - tree.delete(karmaConfig); - } else { + cachedResult = { analysis, isRemovable }; + cache.set(karmaConfig, cachedResult); + } + + if (cachedResult) { + extractReporters(cachedResult.analysis, options, projectName, context); + extractCoverageSettings(cachedResult.analysis, options, projectName, context); + + if (!cachedResult.isRemovable) { 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 ` + @@ -164,5 +169,6 @@ export async function processKarmaConfig( 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 index dbf169ec22e5..7c2d4924420e 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -14,14 +14,14 @@ 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'; +import { KarmaConfigProcessingResult, processKarmaConfig } from './karma-processor'; async function processTestTargetOptions( testTarget: TargetDefinition, projectName: string, context: SchematicContext, tree: Tree, - removableKarmaConfigs: Map, + removableKarmaConfigs: Map, customBuildOptions: Record>, needDevkitPlugin: boolean, manualMigrationFiles: string[], @@ -122,7 +122,7 @@ async function processTestTargetOptions( function updateProjects(tree: Tree, context: SchematicContext): Rule { return updateWorkspace(async (workspace) => { let needsCoverage = false; - const removableKarmaConfigs = new Map(); + const removableKarmaConfigs = new Map(); const migratedProjects: string[] = []; const skippedNonApplications: string[] = []; const skippedMissingAppBuilder: string[] = []; @@ -235,6 +235,13 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { migratedProjects.push(projectName); } + // Perform cleanup of removable karma config files + for (const [configPath, result] of removableKarmaConfigs) { + if (result.isRemovable && tree.exists(configPath)) { + tree.delete(configPath); + } + } + // Log summary context.logger.info('\n--- Karma to Vitest Migration Summary ---'); context.logger.info(`Projects migrated: ${migratedProjects.length}`); 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 index e206002ab665..243835f99b91 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts @@ -322,6 +322,7 @@ module.exports = function (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'; @@ -333,6 +334,7 @@ module.exports = function (config) { 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 = { @@ -350,6 +352,7 @@ module.exports = function (config) { ]); 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; @@ -360,4 +363,61 @@ module.exports = function (config) { expect(devDependencies['@vitest/coverage-v8']).toBe(latestVersions['@vitest/coverage-v8']); }); + + it('should successfully extract settings across multiple projects sharing the same removable karma config', async () => { + const { projects } = tree.readJson('/angular.json') as any; + projects.app.targets.test.builder = '@angular-devkit/build-angular:karma'; + + // Add a second project sharing the exact same configuration + projects.app2 = { + ...JSON.parse(JSON.stringify(projects.app)), + root: 'app2', + }; + + 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); + const { projects: newProjects } = newTree.readJson('/angular.json') as any; + + // Assert BOTH projects got the extraction logic mapped correctly + expect(newProjects.app.targets.test.options.reporters).toEqual(['default']); + expect(newProjects.app2.targets.test.options.reporters).toEqual(['default']); + + // Assert that the deletion deferred successfully until BOTH extracted the data + expect(newTree.exists('karma.conf.js')).toBeFalse(); + }); }); From 0d1f298448a50e155b9eb1561978d78b571bce5b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:12:43 -0400 Subject: [PATCH 3/7] refactor(@schematics/angular): support parsing plain template literals in karma config analyzer Updates the AST analyzer to accept static backtick strings that do not contain runtime expressions. Previously, even simple template literals resulted in a fallback warning flag, triggering manual migration overrides unnecessarily. --- .../karma-config-analyzer.ts | 3 +- .../karma-config-analyzer_spec.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer.ts index 79bfa9e98a41..d39e1a16bab6 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer.ts @@ -80,7 +80,8 @@ export function analyzeKarmaConfig(content: string): KarmaConfigAnalysis { function extractValue(node: ts.Expression): KarmaConfigValue { switch (node.kind) { case ts.SyntaxKind.StringLiteral: - return (node as ts.StringLiteral).text; + case ts.SyntaxKind.NoSubstitutionTemplateLiteral: + return (node as ts.StringLiteral | ts.NoSubstitutionTemplateLiteral).text; case ts.SyntaxKind.NumericLiteral: return Number((node as ts.NumericLiteral).text); case ts.SyntaxKind.TrueKeyword: diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer_spec.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer_spec.ts index 79af125c8690..e6af7f09d805 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer_spec.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/karma-config-analyzer_spec.ts @@ -293,4 +293,33 @@ describe('Karma Config Analyzer', () => { expect(settings.size).toBe(0); expect(hasUnsupportedValues).toBe(true); }); + + it('should parse plain template literal strings without substitution', () => { + const karmaConf = ` + module.exports = function (config) { + config.set({ + basePath: \`some/path\`, + }); + }; + `; + const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf); + + expect(settings.get('basePath') as unknown).toBe('some/path'); + expect(hasUnsupportedValues).toBe(false); + }); + + it('should flag template literals with substitution as unsupported', () => { + const karmaConf = ` + const relativePath = './coverage'; + module.exports = function (config) { + config.set({ + basePath: \`\${relativePath}/test\`, + }); + }; + `; + const { settings, hasUnsupportedValues } = analyzeKarmaConfig(karmaConf); + + expect(settings.get('basePath')).toBeUndefined(); + expect(hasUnsupportedValues).toBe(true); + }); }); From 8d0805dd1750cb16af620811dc01b40e46ad030e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:25:59 -0400 Subject: [PATCH 4/7] feat(@schematics/angular): update TSConfig globals during karma to vitest migration Automates the transition of developer test configurations from Jasmine typing providers to Vitest globals definitions. The tool parses each collected tsconfig path and actively relocates 'vitest/globals' into the compiler options type list while removing the 'jasmine' package. --- .../migrate-karma-to-vitest/migration.ts | 54 +++++++++++++++++++ .../migrate-karma-to-vitest/migration_spec.ts | 22 ++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts index 7c2d4924420e..ea4af6849860 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -8,8 +8,10 @@ import type { json } from '@angular-devkit/core'; import { Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics'; +import { join } from 'node:path/posix'; import { isDeepStrictEqual } from 'util'; import { DependencyType, ExistingBehavior, addDependency } from '../../utility/dependency'; +import { JSONFile } from '../../utility/json-file'; import { latestVersions } from '../../utility/latest-versions'; import { TargetDefinition, allTargetOptions, updateWorkspace } from '../../utility/workspace'; import { Builders } from '../../utility/workspace-models'; @@ -119,11 +121,45 @@ async function processTestTargetOptions( return needsCoverage; } +function updateTsConfigTypes( + tree: Tree, + tsConfigsToUpdate: Set, + context: SchematicContext, +): void { + for (const tsConfigPath of tsConfigsToUpdate) { + if (tree.exists(tsConfigPath)) { + try { + const json = new JSONFile(tree, tsConfigPath); + const typesPath = ['compilerOptions', 'types']; + const existingTypes = (json.get(typesPath) as string[] | undefined) ?? []; + const newTypes = existingTypes.filter((t) => t !== 'jasmine'); + + if (!newTypes.includes('vitest/globals')) { + newTypes.push('vitest/globals'); + } + + if ( + newTypes.length !== existingTypes.length || + newTypes.some((t, i) => t !== existingTypes[i]) + ) { + json.modify(typesPath, newTypes); + } + } catch (err) { + context.logger.warn( + `Failed to automatically update types in "${tsConfigPath}". ` + + `Please manually remove "jasmine" and add "vitest/globals" to compilerOptions.types.`, + ); + } + } + } +} + function updateProjects(tree: Tree, context: SchematicContext): Rule { return updateWorkspace(async (workspace) => { let needsCoverage = false; const removableKarmaConfigs = new Map(); const migratedProjects: string[] = []; + const tsConfigsToUpdate = new Set(); const skippedNonApplications: string[] = []; const skippedMissingAppBuilder: string[] = []; const manualMigrationFiles: string[] = []; @@ -169,6 +205,21 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { continue; } + // Collect tsConfig paths to perform globals updates + const baseTsConfig = testTarget.options?.['tsConfig']; + if (typeof baseTsConfig === 'string') { + tsConfigsToUpdate.add(baseTsConfig); + } + if (testTarget.configurations) { + for (const config of Object.values(testTarget.configurations)) { + if (typeof config?.['tsConfig'] === 'string') { + tsConfigsToUpdate.add(config['tsConfig']); + } + } + } + // Always include fallback to the default tsconfig.spec.json path + tsConfigsToUpdate.add(join(project.root, 'tsconfig.spec.json')); + // Store custom build options to move to a new build configuration if needed const customBuildOptions: Record> = {}; @@ -242,6 +293,9 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { } } + // Update TSConfig files to use Vitest types instead of Jasmine + updateTsConfigTypes(tree, tsConfigsToUpdate, context); + // Log summary context.logger.info('\n--- Karma to Vitest Migration Summary ---'); context.logger.info(`Projects migrated: ${migratedProjects.length}`); 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 index 243835f99b91..fb6737b77d26 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts @@ -420,4 +420,26 @@ module.exports = function (config) { // Assert that the deletion deferred successfully until BOTH extracted the data expect(newTree.exists('karma.conf.js')).toBeFalse(); }); + + it('should automatically transition types in referenced tsconfigs from jasmine to vitest/globals', async () => { + // Create a virtual tsconfig that mimics existing state + tree.create( + 'tsconfig.spec.json', + JSON.stringify({ + compilerOptions: { + outDir: './out-tsc/spec', + types: ['jasmine', 'node'], + }, + files: ['src/test.ts'], + include: ['src/**/*.spec.ts', 'src/**/*.d.ts'], + }), + ); + + const newTree = await schematicRunner.runSchematic('migrate-karma-to-vitest', {}, tree); + const tsConfigJson = JSON.parse(newTree.readText('tsconfig.spec.json')); + + expect(tsConfigJson.compilerOptions.types).toContain('vitest/globals'); + expect(tsConfigJson.compilerOptions.types).not.toContain('jasmine'); + expect(tsConfigJson.compilerOptions.types).toContain('node'); + }); }); From b2f7a038b4a321e4e1b0b340cd09425f948c77ad Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:33:50 -0400 Subject: [PATCH 5/7] feat(@schematics/angular): conditionally install istanbul coverage provider for Vitest migration Recognize non-Chromium browser usage during the Karma to Vitest migration. While Node-based and Chromium environments can leverage @vitest/coverage-v8, browser-based test runs using engines such as Firefox or WebKit natively necessitate istanbul instrumentation. --- .../migrate-karma-to-vitest/migration.ts | 114 +++++++++++++----- .../utility/latest-versions/package.json | 1 + 2 files changed, 82 insertions(+), 33 deletions(-) diff --git a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts index ea4af6849860..7d2306428b32 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -27,8 +27,9 @@ async function processTestTargetOptions( customBuildOptions: Record>, needDevkitPlugin: boolean, manualMigrationFiles: string[], -): Promise { +): Promise<{ needsCoverage: boolean; needsIstanbul: boolean }> { let needsCoverage = false; + let needsIstanbul = false; for (const [configName, options] of allTargetOptions(testTarget, false)) { const configKey = configName || ''; if (!customBuildOptions[configKey]) { @@ -81,6 +82,20 @@ async function processTestTargetOptions( const updatedBrowsers = options['browsers']; if (Array.isArray(updatedBrowsers) && updatedBrowsers.length > 0) { + const hasNonChromium = updatedBrowsers.some((b) => { + if (typeof b !== 'string') { + return false; + } + + const normalized = b.toLowerCase(); + + return !['chrome', 'chromium', 'edge'].some((name) => normalized.includes(name)); + }); + + if (hasNonChromium) { + needsIstanbul = true; + } + context.logger.info( `Project "${projectName}" has browsers configured for tests. ` + `To run tests in a browser with Vitest, you will need to install either ` + @@ -118,7 +133,7 @@ async function processTestTargetOptions( delete options['main']; } - return needsCoverage; + return { needsCoverage, needsIstanbul }; } function updateTsConfigTypes( @@ -154,9 +169,49 @@ function updateTsConfigTypes( } } +function logSummary( + context: SchematicContext, + migratedProjects: string[], + skippedNonApplications: string[], + skippedMissingAppBuilder: string[], + manualMigrationFiles: string[], +): void { + 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}`); + } + } + if (migratedProjects.length > 0) { + context.logger.info( + `\nNote: To refactor your test files from Jasmine to Vitest, consider running the following command:` + + `\n ng g @schematics/angular:refactor-jasmine-vitest `, + ); + } + context.logger.info('-----------------------------------------\n'); +} + function updateProjects(tree: Tree, context: SchematicContext): Rule { return updateWorkspace(async (workspace) => { let needsCoverage = false; + let needsIstanbul = false; const removableKarmaConfigs = new Map(); const migratedProjects: string[] = []; const tsConfigsToUpdate = new Set(); @@ -223,7 +278,7 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { // Store custom build options to move to a new build configuration if needed const customBuildOptions: Record> = {}; - const projectNeedsCoverage = await processTestTargetOptions( + const projectCoverageInfo = await processTestTargetOptions( testTarget, projectName, context, @@ -234,8 +289,11 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { manualMigrationFiles, ); - if (projectNeedsCoverage) { + if (projectCoverageInfo.needsCoverage) { needsCoverage = true; + if (projectCoverageInfo.needsIstanbul) { + needsIstanbul = true; + } } // If we have custom build options, create testing configurations @@ -297,36 +355,13 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { updateTsConfigTypes(tree, tsConfigsToUpdate, context); // 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}`, + logSummary( + context, + migratedProjects, + skippedNonApplications, + skippedMissingAppBuilder, + manualMigrationFiles, ); - 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}`); - } - } - if (migratedProjects.length > 0) { - context.logger.info( - `\nNote: To refactor your test files from Jasmine to Vitest, consider running the following command:` + - `\n ng g @schematics/angular:refactor-jasmine-vitest `, - ); - } - context.logger.info('-----------------------------------------\n'); if (migratedProjects.length > 0) { const rules = [ @@ -343,6 +378,19 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { existing: ExistingBehavior.Skip, }), ); + + if (needsIstanbul) { + rules.push( + addDependency( + '@vitest/coverage-istanbul', + latestVersions['@vitest/coverage-istanbul'], + { + type: DependencyType.Dev, + existing: ExistingBehavior.Skip, + }, + ), + ); + } } return chain(rules); diff --git a/packages/schematics/angular/utility/latest-versions/package.json b/packages/schematics/angular/utility/latest-versions/package.json index e59890243153..4ad8149fe646 100644 --- a/packages/schematics/angular/utility/latest-versions/package.json +++ b/packages/schematics/angular/utility/latest-versions/package.json @@ -27,6 +27,7 @@ "typescript": "~6.0.2", "vitest": "^4.0.8", "@vitest/coverage-v8": "^4.0.8", + "@vitest/coverage-istanbul": "^4.0.8", "@vitest/browser-playwright": "^4.0.8", "@vitest/browser-webdriverio": "^4.0.8", "@vitest/browser-preview": "^4.0.8", From 7fb59eaa65a8d7e880b6f44d715b2aeaff9301ca Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 4 May 2026 18:01:42 +0200 Subject: [PATCH 6/7] fix(@schematics/angular): use service decorator in ng generate Updates the `service` schematic in `ng generate` to use the `@Service` decorator instead of `@Injectable`. There's also a new `--injectable` flag to get back the old behavior. --- ...name@dasherize__.__type@dasherize__.ts.template | 6 +++--- packages/schematics/angular/service/index_spec.ts | 14 +++++++++++++- packages/schematics/angular/service/schema.json | 5 +++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/schematics/angular/service/files/__name@dasherize__.__type@dasherize__.ts.template b/packages/schematics/angular/service/files/__name@dasherize__.__type@dasherize__.ts.template index de24346572c2..3b9a10da6ce4 100644 --- a/packages/schematics/angular/service/files/__name@dasherize__.__type@dasherize__.ts.template +++ b/packages/schematics/angular/service/files/__name@dasherize__.__type@dasherize__.ts.template @@ -1,8 +1,8 @@ -import { Injectable } from '@angular/core'; +import { <%= injectable ? 'Injectable' : 'Service' %> } from '@angular/core'; -@Injectable({ +<% if (injectable) { %>@Injectable({ providedIn: 'root', -}) +})<% } else { %>@Service()<% } %> export class <%= classifiedName %> { } diff --git a/packages/schematics/angular/service/index_spec.ts b/packages/schematics/angular/service/index_spec.ts index 56ae5edd2428..023b738b46c7 100644 --- a/packages/schematics/angular/service/index_spec.ts +++ b/packages/schematics/angular/service/index_spec.ts @@ -36,6 +36,7 @@ describe('Service Schematic', () => { skipPackageJson: false, }; let appTree: UnitTestTree; + beforeEach(async () => { appTree = await schematicRunner.runSchematic('workspace', workspaceOptions); appTree = await schematicRunner.runSchematic('application', appOptions, appTree); @@ -50,12 +51,23 @@ describe('Service Schematic', () => { expect(files).toContain('/projects/bar/src/app/foo/foo.ts'); }); - it('service should be tree-shakeable', async () => { + it('should use @Service decorator', async () => { const options = { ...defaultOptions }; const tree = await schematicRunner.runSchematic('service', options, appTree); const content = tree.readContent('/projects/bar/src/app/foo/foo.ts'); + expect(content).toMatch(/@Service\(\)/); + expect(content).toMatch(/import \{ Service \} from '@angular\/core'/); + }); + + it('should use @Injectable decorator when injectable flag is true', async () => { + const options = { ...defaultOptions, injectable: true }; + + const tree = await schematicRunner.runSchematic('service', options, appTree); + const content = tree.readContent('/projects/bar/src/app/foo/foo.ts'); + expect(content).toMatch(/@Injectable\(/); expect(content).toMatch(/providedIn: 'root',/); + expect(content).toMatch(/import \{ Injectable \} from '@angular\/core'/); }); it('should respect the skipTests flag', async () => { diff --git a/packages/schematics/angular/service/schema.json b/packages/schematics/angular/service/schema.json index 19afac150262..a3ebf9d3a3fe 100644 --- a/packages/schematics/angular/service/schema.json +++ b/packages/schematics/angular/service/schema.json @@ -48,6 +48,11 @@ "type": "boolean", "default": true, "description": "When true, the 'type' option will be appended to the generated class name. When false, only the file name will include the type." + }, + "injectable": { + "type": "boolean", + "default": false, + "description": "When true, generates an `@Injectable` instead of `@Service`." } }, "required": ["name", "project"] From d227e6985ef5540e0eea2571577ee2b9be0d3c64 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Tue, 17 Mar 2026 14:21:39 +0100 Subject: [PATCH 7/7] feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../angular/refactor/jasmine-vitest/index.ts | 1 + .../refactor/jasmine-vitest/schema.json | 5 + .../test-file-transformer.integration_spec.ts | 64 ++++- .../jasmine-vitest/test-file-transformer.ts | 44 ++- .../test-file-transformer_add-imports_spec.ts | 28 ++ .../refactor/jasmine-vitest/test-helpers.ts | 1 + .../fake-async-flush-microtasks.ts | 38 +++ .../fake-async-flush-microtasks_spec.ts | 43 +++ .../transformers/fake-async-flush.ts | 60 +++++ .../transformers/fake-async-flush_spec.ts | 108 ++++++++ .../transformers/fake-async-test.ts | 211 +++++++++++++++ .../transformers/fake-async-test_spec.ts | 250 ++++++++++++++++++ .../transformers/fake-async-tick.ts | 41 +++ .../transformers/fake-async-tick_spec.ts | 65 +++++ .../transformers/jasmine-misc.ts | 16 +- .../transformers/jasmine-spy.ts | 25 +- .../jasmine-vitest/utils/ast-helpers.ts | 124 +++++++-- .../jasmine-vitest/utils/constants.ts | 9 + .../jasmine-vitest/utils/refactor-context.ts | 6 + .../jasmine-vitest/utils/refactor-helpers.ts | 65 +++++ .../jasmine-vitest/utils/todo-notes.ts | 7 + 21 files changed, 1165 insertions(+), 46 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index 4ae4077a7be4..493bb0eb1800 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -122,6 +122,7 @@ export default function (options: Schema): Rule { const newContent = transformJasmineToVitest(file, content, reporter, { addImports: !!options.addImports, browserMode: !!options.browerMode, + fakeAsync: !!options.fakeAsync, }); if (content !== newContent) { diff --git a/packages/schematics/angular/refactor/jasmine-vitest/schema.json b/packages/schematics/angular/refactor/jasmine-vitest/schema.json index 4192a27367fd..5b23618cbc48 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/schema.json +++ b/packages/schematics/angular/refactor/jasmine-vitest/schema.json @@ -36,6 +36,11 @@ "description": "Whether the tests are intended to run in browser mode. If true, the `toHaveClass` assertions are left as is because Vitest browser mode has such an assertion. Otherwise they're migrated to an equivalent assertion.", "default": false }, + "fakeAsync": { + "type": "boolean", + "description": "Whether to transform `fakeAsync` tests to Vitest fake timers.", + "default": false + }, "report": { "type": "boolean", "description": "Whether to generate a summary report file (jasmine-vitest-.md) in the project root.", diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.integration_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.integration_spec.ts index 2636a142d4b6..016c8d9fe1d4 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.integration_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.integration_spec.ts @@ -14,7 +14,7 @@ import { RefactorReporter } from './utils/refactor-reporter'; async function expectTransformation( input: string, expected: string, - options: { addImports: boolean; browserMode: boolean } = { + options: { addImports: boolean; browserMode: boolean; fakeAsync?: boolean } = { addImports: false, browserMode: false, }, @@ -534,4 +534,66 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { await expectTransformation(jasmineCode, vitestCode); }); + + it('should not transform `fakeAsync`', async () => { + const jasmineCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + const vitestCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + + await expectTransformation(jasmineCode, vitestCode); + }); + + it('should transform `fakeAsync` if `fakeAsync` option is true', async () => { + const jasmineCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + const vitestCode = ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(100); + }); + }); + `; + + await expectTransformation(jasmineCode, vitestCode, { + addImports: false, + browserMode: false, + fakeAsync: true, + }); + }); }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts index de80052d0b2a..f652368b03f7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -14,6 +14,10 @@ */ import ts from 'typescript'; +import { transformFakeAsyncFlush } from './transformers/fake-async-flush'; +import { transformFakeAsyncFlushMicrotasks } from './transformers/fake-async-flush-microtasks'; +import { transformFakeAsyncTest } from './transformers/fake-async-test'; +import { transformFakeAsyncTick } from './transformers/fake-async-tick'; import { transformDoneCallback, transformFocusedAndSkippedTests, @@ -48,7 +52,11 @@ import { transformSpyReset, } from './transformers/jasmine-spy'; import { transformJasmineTypes } from './transformers/jasmine-type'; -import { addVitestValueImport, getVitestAutoImports } from './utils/ast-helpers'; +import { + addVitestValueImport, + getVitestAutoImports, + removeImportSpecifiers, +} from './utils/ast-helpers'; import { RefactorContext } from './utils/refactor-context'; import { RefactorReporter } from './utils/refactor-reporter'; @@ -129,6 +137,10 @@ const callExpressionTransformers = [ transformToHaveBeenCalledBefore, transformToHaveClass, transformToBeNullish, + transformFakeAsyncTest, + transformFakeAsyncTick, + transformFakeAsyncFlush, + transformFakeAsyncFlushMicrotasks, // **Stage 3: Global Functions & Cleanup** // These handle global Jasmine functions and catch-alls for unsupported APIs. @@ -173,8 +185,10 @@ export function transformJasmineToVitest( filePath: string, content: string, reporter: RefactorReporter, - options: { addImports: boolean; browserMode: boolean }, + options: { addImports: boolean; browserMode: boolean; fakeAsync?: boolean }, ): string { + options.fakeAsync ??= false; + const contentWithPlaceholders = preserveBlankLines(content); const sourceFile = ts.createSourceFile( @@ -187,6 +201,7 @@ export function transformJasmineToVitest( const pendingVitestValueImports = new Set(); const pendingVitestTypeImports = new Set(); + const pendingImportSpecifierRemovals = new Map>(); const transformer: ts.TransformerFactory = (context) => { const refactorCtx: RefactorContext = { @@ -195,6 +210,7 @@ export function transformJasmineToVitest( tsContext: context, pendingVitestValueImports, pendingVitestTypeImports, + pendingImportSpecifierRemovals, }; const visitor: ts.Visitor = (node) => { @@ -211,7 +227,18 @@ export function transformJasmineToVitest( } for (const transformer of callExpressionTransformers) { - if (!(options.browserMode && transformer === transformToHaveClass)) { + if ( + !( + (options.browserMode && transformer === transformToHaveClass) || + (options.fakeAsync === false && + [ + transformFakeAsyncFlush, + transformFakeAsyncFlushMicrotasks, + transformFakeAsyncTick, + transformFakeAsyncTest, + ].includes(transformer)) + ) + ) { transformedNode = transformer(transformedNode, refactorCtx); } } @@ -249,16 +276,25 @@ export function transformJasmineToVitest( const hasPendingValueImports = pendingVitestValueImports.size > 0; const hasPendingTypeImports = pendingVitestTypeImports.size > 0; + const hasPendingImportSpecifierRemovals = pendingImportSpecifierRemovals.size > 0; if ( transformedSourceFile === sourceFile && !reporter.hasTodos && !hasPendingValueImports && - !hasPendingTypeImports + !hasPendingTypeImports && + !hasPendingImportSpecifierRemovals ) { return content; } + if (hasPendingImportSpecifierRemovals) { + transformedSourceFile = removeImportSpecifiers( + transformedSourceFile, + pendingImportSpecifierRemovals, + ); + } + if (hasPendingTypeImports || (options.addImports && hasPendingValueImports)) { const vitestImport = getVitestAutoImports( options.addImports ? pendingVitestValueImports : new Set(), diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts index 2eaca1f5bf15..c835dc9640c5 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts @@ -150,4 +150,32 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { true, ); }); + + it('should add imports for `vi` when addImports is true', async () => { + const input = ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `; + const expected = ` + import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + expect(1).toBe(1); + }); + }); + `; + await expectTransformation(input, expected, true); + }); }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts index 9aa6532206da..6986c4c39d0c 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts @@ -33,6 +33,7 @@ export async function expectTransformation( const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports, browserMode: false, + fakeAsync: true, }); const formattedTransformed = await format(transformed, { parser: 'typescript' }); const formattedExpected = await format(expected, { parser: 'typescript' }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts new file mode 100644 index 000000000000..59064bce4f18 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts @@ -0,0 +1,38 @@ +/** + * @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 ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncFlushMicrotasks(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flushMicrotasks' && + isNamedImportFrom(ctx.sourceFile, 'flushMicrotasks', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, + ); + + addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); + + return ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [ts.factory.createNumericLiteral(0)]), + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts new file mode 100644 index 000000000000..16f9eb8e88e2 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts @@ -0,0 +1,43 @@ +/** + * @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 { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncFlushMicrotasks', () => { + const testCases = [ + { + description: 'should replace `flushMicrotasks` with `await vi.advanceTimersByTimeAsync(0)`', + input: ` + import { flushMicrotasks } from '@angular/core/testing'; + + flushMicrotasks(); + `, + expected: `await vi.advanceTimersByTimeAsync(0);`, + }, + { + description: + 'should not replace `flushMicrotasks` if not imported from `@angular/core/testing`', + input: ` + import { flushMicrotasks } from './my-flush-microtasks'; + + flushMicrotasks(); + `, + expected: ` + import { flushMicrotasks } from './my-flush-microtasks'; + + flushMicrotasks(); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts new file mode 100644 index 000000000000..5235ea8e1abf --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -0,0 +1,60 @@ +/** + * @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 ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { addTodoComment } from '../utils/comment-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flush' && + isNamedImportFrom(ctx.sourceFile, 'flush', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, + ); + + addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); + + if (node.arguments.length > 0) { + ctx.reporter.recordTodo('flush-max-turns', ctx.sourceFile, node); + addTodoComment(node, 'flush-max-turns'); + } + + const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'runAllTimersAsync'), + ); + + if (ts.isExpressionStatement(node.parent)) { + return awaitRunAllTimersAsync; + } else { + // If `flush` is not used as its own statement, then the return value is probably used. + // Therefore, we replace it with nullish coalescing that returns 0: + // > await vi.runAllTimersAsync() ?? 0; + ctx.reporter.recordTodo('flush-return-value', ctx.sourceFile, node); + addTodoComment(node, 'flush-return-value'); + + return ts.factory.createBinaryExpression( + awaitRunAllTimersAsync, + ts.SyntaxKind.QuestionQuestionToken, + ts.factory.createNumericLiteral(0), + ); + } +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts new file mode 100644 index 000000000000..1daeda891461 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts @@ -0,0 +1,108 @@ +/** + * @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 { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncFlush', () => { + const testCases = [ + { + description: 'should replace `flush` with `await vi.runAllTimersAsync()`', + input: ` + import { flush } from '@angular/core/testing'; + + flush(); + `, + expected: `await vi.runAllTimersAsync();`, + }, + { + description: 'should add TODO comment when flush is called with maxTurns', + input: ` + import { flush } from '@angular/core/testing'; + + flush(42); + `, + expected: ` + // TODO: vitest-migration: flush(maxTurns) was called but maxTurns parameter is not migrated. Please migrate manually. + await vi.runAllTimersAsync(); + `, + }, + { + description: 'should add TODO comment when flush return value is used', + input: ` + import { flush } from '@angular/core/testing'; + + const turns = flush(); + `, + expected: ` + // TODO: vitest-migration: flush() return value is not migrated. Please migrate manually. + const turns = await vi.runAllTimersAsync() ?? 0; + `, + }, + { + description: 'should add TODO comment when flush return value is used in a return statement', + input: ` + import { flush } from '@angular/core/testing'; + + async function myFlushWrapper() { + return flush(); + } + `, + expected: ` + async function myFlushWrapper() { + // TODO: vitest-migration: flush() return value is not migrated. Please migrate manually. + return await vi.runAllTimersAsync() ?? 0; + } + `, + }, + { + description: 'should not replace `flush` if not imported from `@angular/core/testing`', + input: ` + import { flush } from './my-flush'; + + flush(); + `, + expected: ` + import { flush } from './my-flush'; + + flush(); + `, + }, + { + description: 'should keep other imported symbols from `@angular/core/testing`', + input: ` + import { TestBed, flush } from '@angular/core/testing'; + + flush(); + `, + expected: ` + import { TestBed } from '@angular/core/testing'; + + await vi.runAllTimersAsync(); + `, + }, + { + description: 'should keep imported types from `@angular/core/testing`', + input: ` + import { flush, type ComponentFixture } from '@angular/core/testing'; + + flush(); + `, + expected: ` + import { type ComponentFixture } from '@angular/core/testing'; + + await vi.runAllTimersAsync(); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts new file mode 100644 index 000000000000..ea2a2ef52cb5 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts @@ -0,0 +1,211 @@ +/** + * @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 ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncTest( + node: ts.Node, + ctx: RefactorContext, + currentOutermostDescribeContext?: CurrentOutermostDescribeContext, +): ts.Node { + // Transform the outermost describe block and skip others. + if (currentOutermostDescribeContext == null && _is.describe(node)) { + return _transformDescribeCall(node, ctx); + } + + // If we encounter a `fakeAsync` call while in a `describe` block, mark it in the context. + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + currentOutermostDescribeContext != null && + node.arguments.length >= 1 && + _is.arrowOrFunction(node.arguments[0]) && + isNamedImportFrom(ctx.sourceFile, 'fakeAsync', ANGULAR_CORE_TESTING) + ) { + return _transformFakeAsyncCall(node, ctx, currentOutermostDescribeContext); + } + + // If we are in a `describe` block, visit the children recursively. + if (currentOutermostDescribeContext != null) { + return ts.visitEachChild( + node, + (child) => transformFakeAsyncTest(child, ctx, currentOutermostDescribeContext), + ctx.tsContext, + ); + } + + return node; +} + +function _transformDescribeCall(node: ts.CallExpression, ctx: RefactorContext): ts.CallExpression { + const currentOutermostDescribeContext: CurrentOutermostDescribeContext = { + isUsingFakeAsync: false, + }; + + // Visit children recursively to collect transform `fakeAsync usages + // within the describe block and detect their presence through `isUsingFakeAsync`. + node = ts.visitEachChild( + node, + (child) => transformFakeAsyncTest(child, ctx, currentOutermostDescribeContext), + ctx.tsContext, + ); + + const { isUsingFakeAsync } = currentOutermostDescribeContext; + + const describeBlock = _findDescribeBlock(node); + if (!isUsingFakeAsync || describeBlock === undefined) { + return node; + } + + addImportSpecifierRemoval(ctx, 'fakeAsync', ANGULAR_CORE_TESTING); + + return ts.factory.updateCallExpression(node, node.expression, node.typeArguments, [ + node.arguments[0], + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([ + ..._createFakeTimersHookStatements(ctx), + ...describeBlock.statements, + ]), + ), + ...node.arguments.slice(2), + ]); +} + +function _transformFakeAsyncCall( + node: ts.CallExpression, + ctx: RefactorContext, + currentOutermostDescribeContext: CurrentOutermostDescribeContext, +): ts.CallExpression | ts.ArrowFunction { + currentOutermostDescribeContext.isUsingFakeAsync = true; + + const fakeAsyncCallback = node.arguments[0]; + if (!_is.arrowOrFunction(fakeAsyncCallback)) { + return node; + } + const callbackBody = ts.isBlock(fakeAsyncCallback.body) + ? fakeAsyncCallback.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(fakeAsyncCallback.body)]); + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`fakeAsync\` to \`vi.useFakeTimers\`.`, + ); + + return ts.factory.createArrowFunction( + [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], + fakeAsyncCallback.typeParameters, + fakeAsyncCallback.parameters, + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock(callbackBody.statements), + ); +} + +function _createFakeTimersHookStatements(ctx: RefactorContext): ts.Statement[] { + return [ + // > beforeAll(() => { + // > vi.useFakeTimers({ + // > advanceTimeDelta: 1, + // > shouldAdvanceTime: true + // > }); + // > }); + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('beforeAll'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock( + [ + ts.factory.createExpressionStatement( + createViCallExpression(ctx, 'useFakeTimers', [ + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'advanceTimeDelta', + ts.factory.createNumericLiteral(1), + ), + ts.factory.createPropertyAssignment( + 'shouldAdvanceTime', + ts.factory.createTrue(), + ), + ]), + ]), + ), + ], + true, + ), + ), + ]), + ), + + // > afterAll(() => { + // > vi.useRealTimers(); + // > }); + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('afterAll'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock( + [ts.factory.createExpressionStatement(createViCallExpression(ctx, 'useRealTimers'))], + true, + ), + ), + ]), + ), + ]; +} + +interface CurrentOutermostDescribeContext { + isUsingFakeAsync: boolean; +} + +function _findDescribeBlock(node: ts.CallExpression): ts.Block | undefined { + const args = node.arguments; + const describeCallback = args.length >= 2 ? args[1] : undefined; + if (describeCallback !== undefined && _is.arrowOrFunction(describeCallback)) { + return _getFunctionBlock(describeCallback); + } + + return undefined; +} + +function _getFunctionBlock(node: ts.FunctionExpression | ts.ArrowFunction): ts.Block { + return ts.isBlock(node.body) + ? node.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(node.body)]); +} + +const _is = { + arrowOrFunction: (node: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression => + ts.isArrowFunction(node) || ts.isFunctionExpression(node), + describe: (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + // describe + ((ts.isIdentifier(node.expression) && node.expression.text === 'describe') || + // describe.only or describe.skip + (ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'describe')), +}; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts new file mode 100644 index 000000000000..5efb23282f5a --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts @@ -0,0 +1,250 @@ +/** + * @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 { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncTest', () => { + const testCases = [ + { + description: 'should transform fakeAsync test to `vi.useFakeTimers()`', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + expect(1).toBe(1); + }); + }); + `, + }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` and keep its arguments but not the return type', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync((strangeArg: Strange = myStrangeDefault): void => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async (strangeArg: Strange = myStrangeDefault) => { + expect(1).toBe(1); + }); + }); + `, + }, + { + description: 'should transform fakeAsync test to `vi.useFakeTimers()` in outer describe', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe('My outer fakeAsync suite', () => { + + describe('My inner fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + + }); + + `, + expected: ` + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe('My outer fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + describe('My inner fakeAsync suite', () => { + it('works', async () => { + expect(1).toBe(1); + }); + }); + }); + `, + }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` in outer describe even if it is excluded', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + xdescribe('My outer fakeAsync suite', () => { + + describe('My inner fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + + }); + + `, + expected: ` + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe.skip('My outer fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + describe('My inner fakeAsync suite', () => { + it('works', async () => { + expect(1).toBe(1); + }); + }); + }); + `, + }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` in `beforeEach`, `afterEach`, `beforeAll`, `afterAll`', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + beforeAll(fakeAsync(() => { + console.log('beforeAll'); + })); + + afterAll(fakeAsync(() => { + console.log('afterAll'); + })); + + beforeEach(fakeAsync(() => { + console.log('beforeEach'); + })); + + afterEach(fakeAsync(() => { + console.log('afterEach'); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + beforeAll(async () => { + console.log('beforeAll'); + }); + + afterAll(async () => { + console.log('afterAll'); + }); + + beforeEach(async () => { + console.log('beforeEach'); + }); + + afterEach(async () => { + console.log('afterEach'); + }); + }); + `, + }, + { + description: 'should not replace `fakeAsync` if not used within a describe block', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + expected: ` + import { fakeAsync } from '@angular/core/testing'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + }, + { + description: 'should not replace `fakeAsync` if not imported from `@angular/core/testing`', + input: ` + import { fakeAsync } from './my-fake-async'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + import { fakeAsync } from './my-fake-async'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts new file mode 100644 index 000000000000..91932bad957e --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts @@ -0,0 +1,41 @@ +/** + * @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 ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'tick' && + isNamedImportFrom(ctx.sourceFile, 'tick', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, + ); + + addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); + + const durationNumericLiteral = + node.arguments.length > 0 ? node.arguments[0] : ts.factory.createNumericLiteral(0); + + return ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [durationNumericLiteral]), + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts new file mode 100644 index 000000000000..b8c3b947a160 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts @@ -0,0 +1,65 @@ +/** + * @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 { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncTick', () => { + const testCases = [ + { + description: 'should replace `tick` with `vi.advanceTimersByTimeAsync`', + input: ` + import { tick } from '@angular/core/testing'; + + tick(100); + `, + expected: `await vi.advanceTimersByTimeAsync(100);`, + }, + { + description: + 'should replace `tick` with `vi.advanceTimersByTimeAsync` even if it using a non-literal argument', + input: ` + import { tick } from '@angular/core/testing'; + + const duration = 100; + tick(duration); + `, + expected: ` + const duration = 100; + await vi.advanceTimersByTimeAsync(duration); + `, + }, + { + description: 'should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', + input: ` + import { tick } from '@angular/core/testing'; + + tick(); + `, + expected: `await vi.advanceTimersByTimeAsync(0);`, + }, + { + description: 'should not replace `tick` if not imported from `@angular/core/testing`', + input: ` + import { tick } from './my-tick'; + + tick(100); + `, + expected: ` + import { tick } from './my-tick'; + + tick(100); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts index 243eea1b2878..6832e36b9273 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -14,16 +14,15 @@ */ import ts from 'typescript'; -import { addVitestValueImport, createViCallExpression } from '../utils/ast-helpers'; +import { addVitestValueImport } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; +import { createViCallExpression } from '../utils/refactor-helpers'; import { TodoCategory } from '../utils/todo-notes'; -export function transformTimerMocks( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformTimerMocks(node: ts.Node, ctx: RefactorContext): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; if ( !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || @@ -85,7 +84,7 @@ export function transformTimerMocks( ]; } - return createViCallExpression(newMethodName, newArgs); + return createViCallExpression(ctx, newMethodName, newArgs); } return node; @@ -173,15 +172,16 @@ export function transformJasmineMembers(node: ts.Node, refactorCtx: RefactorCont function transformJasmineDefaultTimeoutInterval( expression: ts.ExpressionStatement, timeoutValue: ts.Expression, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, + ctx: RefactorContext, ): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, expression, 'Transformed `jasmine.DEFAULT_TIMEOUT_INTERVAL` to `vi.setConfig()`.', ); - const setConfigCall = createViCallExpression('setConfig', [ + const setConfigCall = createViCallExpression(ctx, 'setConfig', [ ts.factory.createObjectLiteralExpression( [ts.factory.createPropertyAssignment('testTimeout', timeoutValue)], false, diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts index 2c9b6f8cc686..543ba5a2daee 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -17,12 +17,12 @@ import ts from 'typescript'; import { addVitestValueImport, createPropertyAccess, - createViCallExpression, getPromiseResolveRejectMethod, } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; +import { createViCallExpression } from '../utils/refactor-helpers'; export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; @@ -219,10 +219,8 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. return node; } -export function transformCreateSpy( - node: ts.Node, - { reporter, sourceFile, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformCreateSpy(node: ts.Node, ctx: RefactorContext): ts.Node { + const { reporter, sourceFile, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpy')) { return node; } @@ -236,6 +234,7 @@ export function transformCreateSpy( const spyName = node.arguments[0]; const viFnCallExpression = createViCallExpression( + ctx, 'fn', node.arguments.length > 1 ? [node.arguments[1]] : [], ); @@ -251,10 +250,8 @@ export function transformCreateSpy( ); } -export function transformCreateSpyObj( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformCreateSpyObj(node: ts.Node, ctx: RefactorContext): ts.Node { + const { reporter, sourceFile, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpyObj')) { return node; } @@ -282,9 +279,9 @@ export function transformCreateSpyObj( } if (ts.isArrayLiteralExpression(methods)) { - properties = createSpyObjWithArray(methods, baseName); + properties = createSpyObjWithArray(ctx, methods, baseName); } else if (ts.isObjectLiteralExpression(methods)) { - properties = createSpyObjWithObject(methods, baseName); + properties = createSpyObjWithObject(ctx, methods, baseName); } else { const category = 'createSpyObj-dynamic-variable'; reporter.recordTodo(category, sourceFile, node); @@ -307,13 +304,14 @@ export function transformCreateSpyObj( } function createSpyObjWithArray( + ctx: RefactorContext, methods: ts.ArrayLiteralExpression, baseName: string | undefined, ): ts.PropertyAssignment[] { return methods.elements .map((element) => { if (ts.isStringLiteral(element)) { - const mockFn = createViCallExpression('fn'); + const mockFn = createViCallExpression(ctx, 'fn'); const methodName = element.text; let finalExpression: ts.Expression = mockFn; @@ -337,6 +335,7 @@ function createSpyObjWithArray( } function createSpyObjWithObject( + ctx: RefactorContext, methods: ts.ObjectLiteralExpression, baseName: string | undefined, ): ts.PropertyAssignment[] { @@ -345,7 +344,7 @@ function createSpyObjWithObject( if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { const methodName = prop.name.text; const returnValue = prop.initializer; - let mockFn = createViCallExpression('fn'); + let mockFn = createViCallExpression(ctx, 'fn'); if (baseName) { mockFn = ts.factory.createCallExpression( diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts index 8cbf089d05a8..19326338e831 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -48,32 +48,17 @@ export function getVitestAutoImports( allSpecifiers.sort((a, b) => a.name.text.localeCompare(b.name.text)); - const importClause = ts.factory.createImportClause( - isClauseTypeOnly, // Set isTypeOnly on the clause if only type imports - undefined, - ts.factory.createNamedImports(allSpecifiers), - ); - return ts.factory.createImportDeclaration( undefined, - importClause, + ts.factory.createImportClause( + isClauseTypeOnly, // Set isTypeOnly on the clause if only type imports + undefined, + ts.factory.createNamedImports(allSpecifiers), + ), ts.factory.createStringLiteral('vitest'), ); } -export function createViCallExpression( - methodName: string, - args: readonly ts.Expression[] = [], - typeArgs: ts.TypeNode[] | undefined = undefined, -): ts.CallExpression { - const callee = ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - methodName, - ); - - return ts.factory.createCallExpression(callee, typeArgs, args); -} - export function createExpectCallExpression( args: ts.Expression[], typeArgs: ts.TypeNode[] | undefined = undefined, @@ -121,3 +106,102 @@ export function getPromiseResolveRejectMethod(node: ts.Node): { arguments: node.arguments, }; } + +/** + * Checks if a named binding is imported from the given module in the source file. + * @param sourceFile The source file to search for imports. + * @param name The import name (e.g. 'flush', 'tick'). + * @param moduleSpecifier The module path (e.g. '@angular/core/testing'). + */ +export function isNamedImportFrom( + sourceFile: ts.SourceFile, + name: string, + moduleSpecifier: string, +): boolean { + return sourceFile.statements.some((statement) => { + if (!_isImportDeclarationWithNamedBindings(statement)) { + return false; + } + + const specifier = statement.moduleSpecifier; + const modulePath = ts.isStringLiteralLike(specifier) ? specifier.text : null; + if (modulePath !== moduleSpecifier) { + return false; + } + for (const element of statement.importClause.namedBindings.elements) { + const importedName = element.propertyName ? element.propertyName.text : element.name.text; + if (importedName === name) { + return true; + } + } + }); +} + +/** + * Removes specified import specifiers from ImportDeclarations. + * If all specifiers are removed from an import, the entire import is dropped. + */ +export function removeImportSpecifiers( + sourceFile: ts.SourceFile, + removals: Map>, +): ts.SourceFile { + const newStatements = sourceFile.statements + .map((statement) => { + if (!_isImportDeclarationWithNamedBindings(statement)) { + return statement; + } + + const specifier = statement.moduleSpecifier; + const modulePath = ts.isStringLiteralLike(specifier) ? specifier.text : null; + if (modulePath === null) { + return statement; + } + + const namesToRemove = removals.get(modulePath); + if (namesToRemove === undefined || namesToRemove.size === 0) { + return statement; + } + + const remaining = statement.importClause.namedBindings.elements.filter((el) => { + const name = el.propertyName ? el.propertyName.text : el.name.text; + + return !namesToRemove.has(name); + }); + + if (remaining.length === 0) { + return; + } + + if (remaining.length === statement.importClause.namedBindings.elements.length) { + return statement; + } + + return ts.factory.updateImportDeclaration( + statement, + statement.modifiers, + ts.factory.updateImportClause( + statement.importClause, + undefined, + statement.importClause.name, + ts.factory.createNamedImports(remaining), + ), + statement.moduleSpecifier, + statement.attributes, + ); + }) + .filter((statement) => statement !== undefined); + + return ts.factory.updateSourceFile(sourceFile, newStatements); +} + +function _isImportDeclarationWithNamedBindings( + statement: ts.Statement, +): statement is ts.ImportDeclaration & { + importClause: ts.ImportClause & { namedBindings: ts.NamedImports }; +} { + return ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings !== undefined && + ts.isNamedImports(statement.importClause.namedBindings) + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts new file mode 100644 index 000000000000..23407fee97da --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts @@ -0,0 +1,9 @@ +/** + * @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 ANGULAR_CORE_TESTING = '@angular/core/testing'; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts index d2599ed16ed7..6aa7052685d3 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -28,6 +28,12 @@ export interface RefactorContext { /** A set of Vitest type imports to be added to the file. */ readonly pendingVitestTypeImports: Set; + + /** + * Map of module specifier -> names to remove from that import. + * Used when transforming identifiers that become inlined (e.g. flush -> vi.runAllTimersAsync). + */ + readonly pendingImportSpecifierRemovals: Map>; } /** diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts new file mode 100644 index 000000000000..616c1bae6114 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts @@ -0,0 +1,65 @@ +/** + * @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 ts from 'typescript'; +import { RefactorContext } from './refactor-context'; + +/** + * Marks an identifier to be removed from an import specifier. + * + * @param ctx The refactor context object. + * @param name The name of the identifier to remove from the import specifier. + * @param moduleSpecifier The module specifier to remove the identifier from. + */ +export function addImportSpecifierRemoval( + ctx: RefactorContext, + name: string, + moduleSpecifier: string, +): void { + const removals = ctx.pendingImportSpecifierRemovals.get(moduleSpecifier) ?? new Set(); + removals.add(name); + ctx.pendingImportSpecifierRemovals.set(moduleSpecifier, removals); +} + +/** + * Creates a call expression to a vitest method. + * This also adds the `vi` identifier to the context object, + * to import it later if addImports option is enabled. + * + * @param ctx The refactor context object. + * @param args The arguments to pass to the method. + * @param typeArgs The type arguments to pass to the method. + * @param methodeName The name of the vitest method to call. + * @returns The created identifier node. + */ +export function createViCallExpression( + ctx: RefactorContext, + methodName: string, + args: readonly ts.Expression[] = [], + typeArgs: ts.TypeNode[] | undefined = undefined, +): ts.CallExpression { + const vi = requireVitestIdentifier(ctx, 'vi'); + const callee = ts.factory.createPropertyAccessExpression(vi, methodName); + + return ts.factory.createCallExpression(callee, typeArgs, args); +} + +/** + * Creates an identifier for a vitest value import. + * This also adds the identifier to the context object, + * to import it later if addImports option is enabled. + * + * @param ctx The refactor context object. + * @param name The name of the vitest identifier to require. + * @returns The created identifier node. + */ +export function requireVitestIdentifier(ctx: RefactorContext, name: string): ts.Identifier { + ctx.pendingVitestValueImports.add(name); + + return ts.factory.createIdentifier(name); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts index 2a3f155a9393..598606d7bde6 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -178,6 +178,13 @@ export const TODO_NOTES = { 'unhandled-done-usage': { message: "The 'done' callback was used in an unhandled way. Please migrate manually.", }, + 'flush-max-turns': { + message: + 'flush(maxTurns) was called but maxTurns parameter is not migrated. Please migrate manually.', + }, + 'flush-return-value': { + message: 'flush() return value is not migrated. Please migrate manually.', + }, } as const; /**