From 0517832b78d0c8681fa61120db0e8dcc9bef6174 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:44:49 -0500 Subject: [PATCH] fix(@schematics/angular): add `addImports` option to jasmine-vitest schematic Introduces a new `--add-imports` boolean option to the Jasmine to Vitest refactoring schematic. When this option is set to `true`, the schematic will automatically add a single, consolidated `import { ... } from 'vitest';` statement at the top of the transformed file for any Vitest APIs that are used. Key changes: - Type-only imports (e.g., `Mock`, `MockedObject`) are now always added to ensure the refactored code remains type-correct. - Value imports (e.g., `vi`, `describe`, `it`, `expect`) are only added when `--add-imports` is `true`. - The import generation logic correctly handles all scenarios, including type-only imports (`import type {...}`), value-only imports, and combined imports with inline `type` keywords. - A new test suite has been added to validate the import generation logic under various conditions. --- .../angular/refactor/jasmine-vitest/index.ts | 4 +- .../refactor/jasmine-vitest/schema.json | 5 ++ .../test-file-transformer.integration_spec.ts | 2 +- .../jasmine-vitest/test-file-transformer.ts | 45 +++++++++++---- .../test-file-transformer_add-imports_spec.ts | 56 +++++++++++++++++++ .../refactor/jasmine-vitest/test-helpers.ts | 8 ++- .../transformers/jasmine-matcher.ts | 9 ++- .../transformers/jasmine-misc.ts | 8 ++- .../transformers/jasmine-spy.ts | 27 ++++++--- .../transformers/jasmine-type.ts | 8 +-- .../jasmine-vitest/utils/ast-helpers.ts | 51 +++++++++++++---- .../jasmine-vitest/utils/refactor-context.ts | 5 +- 12 files changed, 184 insertions(+), 44 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index fb545b8bcbca..a7e34ad26c02 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -119,7 +119,9 @@ export default function (options: Schema): Rule { for (const file of files) { reporter.incrementScannedFiles(); const content = tree.readText(file); - const newContent = transformJasmineToVitest(file, content, reporter); + const newContent = transformJasmineToVitest(file, content, reporter, { + addImports: !!options.addImports, + }); if (content !== newContent) { tree.overwrite(file, newContent); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/schema.json b/packages/schematics/angular/refactor/jasmine-vitest/schema.json index 5756b1f68ce9..99f34057ffb5 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/schema.json +++ b/packages/schematics/angular/refactor/jasmine-vitest/schema.json @@ -25,6 +25,11 @@ "type": "boolean", "description": "Enable verbose logging to see detailed information about the transformations being applied.", "default": false + }, + "addImports": { + "type": "boolean", + "description": "Whether to add imports for the Vitest API. The Angular `unit-test` system automatically uses the Vitest globals option, which means explicit imports for global APIs like `describe`, `it`, `expect`, and `vi` are often not strictly necessary unless Vitest has been configured not to use globals.", + "default": false } } } 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 60e2675b7572..ebc7e1631907 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): Promise { const logger = new logging.NullLogger(); const reporter = new RefactorReporter(logger); - const transformed = transformJasmineToVitest('spec.ts', input, reporter); + const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports: false }); const formattedTransformed = await format(transformed, { parser: 'typescript' }); const formattedExpected = await format(expected, { parser: 'typescript' }); 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 36fa9a856f37..da9bf28ba420 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -39,7 +39,7 @@ import { transformSpyReset, } from './transformers/jasmine-spy'; import { transformJasmineTypes } from './transformers/jasmine-type'; -import { getVitestAutoImports } from './utils/ast-helpers'; +import { addVitestValueImport, getVitestAutoImports } from './utils/ast-helpers'; import { RefactorContext } from './utils/refactor-context'; import { RefactorReporter } from './utils/refactor-reporter'; @@ -61,14 +61,17 @@ function restoreBlankLines(content: string): string { /** * Transforms a string of Jasmine test code to Vitest test code. * This is the main entry point for the transformation. + * @param filePath The path to the file being transformed. * @param content The source code to transform. * @param reporter The reporter to track TODOs. + * @param options Transformation options. * @returns The transformed code. */ export function transformJasmineToVitest( filePath: string, content: string, reporter: RefactorReporter, + options: { addImports: boolean }, ): string { const contentWithPlaceholders = preserveBlankLines(content); @@ -80,13 +83,15 @@ export function transformJasmineToVitest( ts.ScriptKind.TS, ); - const pendingVitestImports = new Set(); + const pendingVitestValueImports = new Set(); + const pendingVitestTypeImports = new Set(); const transformer: ts.TransformerFactory = (context) => { const refactorCtx: RefactorContext = { sourceFile, reporter, tsContext: context, - pendingVitestImports, + pendingVitestValueImports, + pendingVitestTypeImports, }; const visitor: ts.Visitor = (node) => { @@ -94,6 +99,13 @@ export function transformJasmineToVitest( // Transform the node itself based on its type if (ts.isCallExpression(transformedNode)) { + if (options.addImports && ts.isIdentifier(transformedNode.expression)) { + const name = transformedNode.expression.text; + if (name === 'describe' || name === 'it' || name === 'expect') { + addVitestValueImport(pendingVitestValueImports, name); + } + } + const transformations = [ // **Stage 1: High-Level & Context-Sensitive Transformations** // These transformers often wrap or fundamentally change the nature of the call, @@ -171,16 +183,29 @@ export function transformJasmineToVitest( const result = ts.transform(sourceFile, [transformer]); let transformedSourceFile = result.transformed[0]; - if (transformedSourceFile === sourceFile && !reporter.hasTodos && !pendingVitestImports.size) { + const hasPendingValueImports = pendingVitestValueImports.size > 0; + const hasPendingTypeImports = pendingVitestTypeImports.size > 0; + + if ( + transformedSourceFile === sourceFile && + !reporter.hasTodos && + !hasPendingValueImports && + !hasPendingTypeImports + ) { return content; } - const vitestImport = getVitestAutoImports(pendingVitestImports); - if (vitestImport) { - transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [ - vitestImport, - ...transformedSourceFile.statements, - ]); + if (hasPendingTypeImports || (options.addImports && hasPendingValueImports)) { + const vitestImport = getVitestAutoImports( + options.addImports ? pendingVitestValueImports : new Set(), + pendingVitestTypeImports, + ); + if (vitestImport) { + transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [ + vitestImport, + ...transformedSourceFile.statements, + ]); + } } const printer = ts.createPrinter(); 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 new file mode 100644 index 000000000000..4f85efc48807 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts @@ -0,0 +1,56 @@ +/** + * @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('Jasmine to Vitest Transformer', () => { + describe('addImports option', () => { + it('should add value imports when addImports is true', async () => { + const input = `spyOn(foo, 'bar');`; + const expected = ` + import { vi } from 'vitest'; + vi.spyOn(foo, 'bar'); + `; + await expectTransformation(input, expected, true); + }); + + it('should generate a single, combined import for value and type imports when addImports is true', async () => { + const input = ` + let mySpy: jasmine.Spy; + spyOn(foo, 'bar'); + `; + const expected = ` + import { type Mock, vi } from 'vitest'; + + let mySpy: Mock; + vi.spyOn(foo, 'bar'); + `; + await expectTransformation(input, expected, true); + }); + + it('should only add type imports when addImports is false', async () => { + const input = ` + let mySpy: jasmine.Spy; + spyOn(foo, 'bar'); + `; + const expected = ` + import type { Mock } from 'vitest'; + + let mySpy: Mock; + vi.spyOn(foo, 'bar'); + `; + await expectTransformation(input, expected, false); + }); + + it('should not add an import if no Vitest APIs are used, even when addImports is true', async () => { + const input = `const a = 1;`; + const expected = `const a = 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 bf162192ab2a..a93875b912e9 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts @@ -23,10 +23,14 @@ import { RefactorReporter } from './utils/refactor-reporter'; * @param input The Jasmine code snippet to be transformed. * @param expected The expected Vitest code snippet after transformation. */ -export async function expectTransformation(input: string, expected: string): Promise { +export async function expectTransformation( + input: string, + expected: string, + addImports = false, +): Promise { const logger = new logging.NullLogger(); const reporter = new RefactorReporter(logger); - const transformed = transformJasmineToVitest('spec.ts', input, reporter); + const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports }); const formattedTransformed = await format(transformed, { parser: 'typescript' }); const formattedExpected = await format(expected, { parser: 'typescript' }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts index 00ea5fa0e775..b1b746aad504 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -15,7 +15,11 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { createExpectCallExpression, createPropertyAccess } from '../utils/ast-helpers'; +import { + addVitestValueImport, + createExpectCallExpression, + createPropertyAccess, +} from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; @@ -94,7 +98,7 @@ const ASYMMETRIC_MATCHER_NAMES: ReadonlyArray = [ export function transformAsymmetricMatchers( node: ts.Node, - { sourceFile, reporter }: RefactorContext, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, ): ts.Node { if ( ts.isPropertyAccessExpression(node) && @@ -103,6 +107,7 @@ export function transformAsymmetricMatchers( ) { const matcherName = node.name.text; if (ASYMMETRIC_MATCHER_NAMES.includes(matcherName)) { + addVitestValueImport(pendingVitestValueImports, 'expect'); reporter.reportTransformation( sourceFile, node, 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 1a2c15b2c539..f9667701e376 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -14,7 +14,7 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { createViCallExpression } from '../utils/ast-helpers'; +import { addVitestValueImport, createViCallExpression } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; @@ -22,7 +22,7 @@ import { TodoCategory } from '../utils/todo-notes'; export function transformTimerMocks( node: ts.Node, - { sourceFile, reporter }: RefactorContext, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, ): ts.Node { if ( !ts.isCallExpression(node) || @@ -55,6 +55,7 @@ export function transformTimerMocks( } if (newMethodName) { + addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, node, @@ -94,7 +95,7 @@ export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorC export function transformDefaultTimeoutInterval( node: ts.Node, - { sourceFile, reporter }: RefactorContext, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, ): ts.Node { if ( ts.isExpressionStatement(node) && @@ -108,6 +109,7 @@ export function transformDefaultTimeoutInterval( assignment.left.expression.text === 'jasmine' && assignment.left.name.text === 'DEFAULT_TIMEOUT_INTERVAL' ) { + addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, node, 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 6d5a0dc16727..d2d9af5f01a6 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -14,13 +14,17 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { createPropertyAccess, createViCallExpression } from '../utils/ast-helpers'; +import { + addVitestValueImport, + createPropertyAccess, + createViCallExpression, +} from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { - const { sourceFile, reporter } = refactorCtx; + const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; if (!ts.isCallExpression(node)) { return node; } @@ -29,6 +33,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ts.isIdentifier(node.expression) && (node.expression.text === 'spyOn' || node.expression.text === 'spyOnProperty') ) { + addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, node, @@ -181,6 +186,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. const jasmineMethodName = getJasmineMethodName(node); switch (jasmineMethodName) { case 'createSpy': + addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, node, @@ -208,12 +214,13 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. export function transformCreateSpyObj( node: ts.Node, - { sourceFile, reporter }: RefactorContext, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, ): ts.Node { if (!isJasmineCallExpression(node, 'createSpyObj')) { return node; } + addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, node, @@ -328,7 +335,11 @@ function getSpyIdentifierFromCalls(node: ts.PropertyAccessExpression): ts.Expres return undefined; } -function createMockedSpyMockProperty(spyIdentifier: ts.Expression): ts.PropertyAccessExpression { +function createMockedSpyMockProperty( + spyIdentifier: ts.Expression, + pendingVitestValueImports: Set, +): ts.PropertyAccessExpression { + addVitestValueImport(pendingVitestValueImports, 'vi'); const mockedSpy = ts.factory.createCallExpression( createPropertyAccess('vi', 'mocked'), undefined, @@ -340,7 +351,7 @@ function createMockedSpyMockProperty(spyIdentifier: ts.Expression): ts.PropertyA function transformMostRecentArgs( node: ts.Node, - { sourceFile, reporter }: RefactorContext, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, ): ts.Node { // Check 1: Is it a property access for `.args`? if ( @@ -382,7 +393,7 @@ function transformMostRecentArgs( node, 'Transformed `spy.calls.mostRecent().args` to `vi.mocked(spy).mock.lastCall`.', ); - const mockProperty = createMockedSpyMockProperty(spyIdentifier); + const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports); return createPropertyAccess(mockProperty, 'lastCall'); } @@ -397,7 +408,7 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC return node; } - const { sourceFile, reporter } = refactorCtx; + const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; const pae = node.expression; // e.g., mySpy.calls.count const spyIdentifier = ts.isPropertyAccessExpression(pae.expression) @@ -405,7 +416,7 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC : undefined; if (spyIdentifier) { - const mockProperty = createMockedSpyMockProperty(spyIdentifier); + const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports); const callsProperty = createPropertyAccess(mockProperty, 'calls'); const callName = pae.name.text; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts index c39674d38045..a7a0c7bedf80 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts @@ -14,12 +14,12 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { addVitestAutoImport } from '../utils/ast-helpers'; +import { addVitestTypeImport } from '../utils/ast-helpers'; import { RefactorContext } from '../utils/refactor-context'; export function transformJasmineTypes( node: ts.Node, - { sourceFile, reporter, pendingVitestImports }: RefactorContext, + { sourceFile, reporter, pendingVitestTypeImports }: RefactorContext, ): ts.Node { const typeNameNode = ts.isTypeReferenceNode(node) ? node.typeName : node; if ( @@ -40,7 +40,7 @@ export function transformJasmineTypes( node, `Transformed type \`jasmine.Spy\` to \`${vitestTypeName}\`.`, ); - addVitestAutoImport(pendingVitestImports, vitestTypeName); + addVitestTypeImport(pendingVitestTypeImports, vitestTypeName); return ts.factory.createIdentifier(vitestTypeName); } @@ -51,7 +51,7 @@ export function transformJasmineTypes( node, `Transformed type \`jasmine.SpyObj\` to \`${vitestTypeName}\`.`, ); - addVitestAutoImport(pendingVitestImports, vitestTypeName); + addVitestTypeImport(pendingVitestTypeImports, vitestTypeName); if (ts.isTypeReferenceNode(node)) { return ts.factory.updateTypeReferenceNode( 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 5bdb07dd4225..f6f363df1643 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -8,28 +8,55 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -export function addVitestAutoImport(imports: Set, importName: string): void { +export function addVitestValueImport(imports: Set, importName: string): void { imports.add(importName); } -export function getVitestAutoImports(imports: Set): ts.ImportDeclaration | undefined { - if (!imports?.size) { +export function addVitestTypeImport(imports: Set, importName: string): void { + imports.add(importName); +} + +export function getVitestAutoImports( + valueImports: Set, + typeImports: Set, +): ts.ImportDeclaration | undefined { + if (valueImports.size === 0 && typeImports.size === 0) { return undefined; } - const importNames = [...imports]; - importNames.sort(); - const importSpecifiers = importNames.map((i) => - ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(i)), + const isClauseTypeOnly = valueImports.size === 0 && typeImports.size > 0; + const allSpecifiers: ts.ImportSpecifier[] = []; + + // Add value imports + for (const i of [...valueImports].sort()) { + allSpecifiers.push( + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(i)), + ); + } + + // Add type imports + for (const i of [...typeImports].sort()) { + // Only set isTypeOnly on individual specifiers if the clause itself is NOT type-only + allSpecifiers.push( + ts.factory.createImportSpecifier( + !isClauseTypeOnly, + undefined, + ts.factory.createIdentifier(i), + ), + ); + } + + 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, - ts.factory.createImportClause( - ts.SyntaxKind.TypeKeyword, - undefined, - ts.factory.createNamedImports(importSpecifiers), - ), + importClause, ts.factory.createStringLiteral('vitest'), ); } 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 3ca9fd3810e7..a7e705a83ce5 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -23,8 +23,11 @@ export interface RefactorContext { /** The official context from the TypeScript Transformer API. */ readonly tsContext: ts.TransformationContext; + /** A set of Vitest value imports to be added to the file. */ + readonly pendingVitestValueImports: Set; + /** A set of Vitest type imports to be added to the file. */ - readonly pendingVitestImports: Set; + readonly pendingVitestTypeImports: Set; } /**