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/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); + }); }); 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..7d2306428b32 100644 --- a/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts +++ b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration.ts @@ -8,25 +8,28 @@ 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'; 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[], -): 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]) { @@ -79,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 ` + @@ -116,14 +133,88 @@ async function processTestTargetOptions( delete options['main']; } - return needsCoverage; + return { needsCoverage, needsIstanbul }; +} + +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 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; - const removableKarmaConfigs = new Map(); + let needsIstanbul = false; + const removableKarmaConfigs = new Map(); const migratedProjects: string[] = []; + const tsConfigsToUpdate = new Set(); const skippedNonApplications: string[] = []; const skippedMissingAppBuilder: string[] = []; const manualMigrationFiles: string[] = []; @@ -169,10 +260,25 @@ 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> = {}; - const projectNeedsCoverage = await processTestTargetOptions( + const projectCoverageInfo = await processTestTargetOptions( testTarget, projectName, context, @@ -183,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 @@ -235,37 +344,24 @@ function updateProjects(tree: Tree, context: SchematicContext): Rule { 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}`); + // Perform cleanup of removable karma config files + for (const [configPath, result] of removableKarmaConfigs) { + if (result.isRemovable && tree.exists(configPath)) { + tree.delete(configPath); } } - 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'); + + // Update TSConfig files to use Vitest types instead of Jasmine + updateTsConfigTypes(tree, tsConfigsToUpdate, context); + + // Log summary + logSummary( + context, + migratedProjects, + skippedNonApplications, + skippedMissingAppBuilder, + manualMigrationFiles, + ); if (migratedProjects.length > 0) { const rules = [ @@ -282,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/migrations/migrate-karma-to-vitest/migration_spec.ts b/packages/schematics/angular/migrations/migrate-karma-to-vitest/migration_spec.ts index e206002ab665..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 @@ -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,83 @@ 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(); + }); + + 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'); + }); }); 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; /** 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"] 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", 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;