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 8cd4f94eb711..36fa9a856f37 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -38,6 +38,8 @@ import { transformSpyCallInspection, transformSpyReset, } from './transformers/jasmine-spy'; +import { transformJasmineTypes } from './transformers/jasmine-type'; +import { getVitestAutoImports } from './utils/ast-helpers'; import { RefactorContext } from './utils/refactor-context'; import { RefactorReporter } from './utils/refactor-reporter'; @@ -78,11 +80,13 @@ export function transformJasmineToVitest( ts.ScriptKind.TS, ); + const pendingVitestImports = new Set(); const transformer: ts.TransformerFactory = (context) => { const refactorCtx: RefactorContext = { sourceFile, reporter, tsContext: context, + pendingVitestImports, }; const visitor: ts.Visitor = (node) => { @@ -149,6 +153,8 @@ export function transformJasmineToVitest( break; } } + } else if (ts.isQualifiedName(transformedNode) || ts.isTypeReferenceNode(transformedNode)) { + transformedNode = transformJasmineTypes(transformedNode, refactorCtx); } // Visit the children of the node to ensure they are transformed @@ -163,12 +169,22 @@ export function transformJasmineToVitest( }; const result = ts.transform(sourceFile, [transformer]); - if (result.transformed[0] === sourceFile && !reporter.hasTodos) { + let transformedSourceFile = result.transformed[0]; + + if (transformedSourceFile === sourceFile && !reporter.hasTodos && !pendingVitestImports.size) { return content; } + const vitestImport = getVitestAutoImports(pendingVitestImports); + if (vitestImport) { + transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [ + vitestImport, + ...transformedSourceFile.statements, + ]); + } + const printer = ts.createPrinter(); - const transformedContentWithPlaceholders = printer.printFile(result.transformed[0]); + const transformedContentWithPlaceholders = printer.printFile(transformedSourceFile); return restoreBlankLines(transformedContentWithPlaceholders); } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts new file mode 100644 index 000000000000..c39674d38045 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type.ts @@ -0,0 +1,109 @@ +/** + * @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 + */ + +/** + * @fileoverview This file contains a transformer that migrates Jasmine type definitions to + * their Vitest equivalents. It handles the conversion of types like `jasmine.Spy` and + * `jasmine.SpyObj` to Vitest's `Mock` and `MockedObject` types, and ensures that the + * necessary `vitest` imports are added to the file. + */ + +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { addVitestAutoImport } from '../utils/ast-helpers'; +import { RefactorContext } from '../utils/refactor-context'; + +export function transformJasmineTypes( + node: ts.Node, + { sourceFile, reporter, pendingVitestImports }: RefactorContext, +): ts.Node { + const typeNameNode = ts.isTypeReferenceNode(node) ? node.typeName : node; + if ( + !ts.isQualifiedName(typeNameNode) || + !ts.isIdentifier(typeNameNode.left) || + typeNameNode.left.text !== 'jasmine' + ) { + return node; + } + + const jasmineTypeName = typeNameNode.right.text; + + switch (jasmineTypeName) { + case 'Spy': { + const vitestTypeName = 'Mock'; + reporter.reportTransformation( + sourceFile, + node, + `Transformed type \`jasmine.Spy\` to \`${vitestTypeName}\`.`, + ); + addVitestAutoImport(pendingVitestImports, vitestTypeName); + + return ts.factory.createIdentifier(vitestTypeName); + } + case 'SpyObj': { + const vitestTypeName = 'MockedObject'; + reporter.reportTransformation( + sourceFile, + node, + `Transformed type \`jasmine.SpyObj\` to \`${vitestTypeName}\`.`, + ); + addVitestAutoImport(pendingVitestImports, vitestTypeName); + + if (ts.isTypeReferenceNode(node)) { + return ts.factory.updateTypeReferenceNode( + node, + ts.factory.createIdentifier(vitestTypeName), + node.typeArguments, + ); + } + + return ts.factory.createIdentifier(vitestTypeName); + } + case 'Any': + reporter.reportTransformation( + sourceFile, + node, + `Transformed type \`jasmine.Any\` to \`any\`.`, + ); + + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); + case 'ObjectContaining': { + const typeArguments = ts.isTypeReferenceNode(node) ? node.typeArguments : undefined; + if (typeArguments && typeArguments.length > 0) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed type \`jasmine.ObjectContaining\` to \`Partial\`.`, + ); + + return ts.factory.createTypeReferenceNode('Partial', typeArguments); + } + + reporter.reportTransformation( + sourceFile, + node, + `Transformed type \`jasmine.ObjectContaining\` to \`object\`.`, + ); + + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword); + } + case 'DoneFn': + reporter.reportTransformation( + sourceFile, + node, + 'Transformed type `jasmine.DoneFn` to `() => void`.', + ); + + return ts.factory.createFunctionTypeNode( + undefined, + [], + ts.factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword), + ); + } + + return node; +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type_spec.ts new file mode 100644 index 000000000000..d38891730f99 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-type_spec.ts @@ -0,0 +1,76 @@ +/** + * @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('transformJasmineTypes', () => { + const testCases = [ + { + description: 'should transform a variable with a jasmine.Spy type', + input: `let mySpy: jasmine.Spy;`, + expected: ` + import type { Mock } from 'vitest'; + let mySpy: Mock; + `, + }, + { + description: 'should transform a variable with a jasmine.SpyObj type', + input: `let mySpy: jasmine.SpyObj;`, + expected: ` + import type { MockedObject } from 'vitest'; + let mySpy: MockedObject; + `, + }, + { + description: 'should handle multiple jasmine types and create a single import', + input: ` + let mySpy: jasmine.Spy; + let mySpyObj: jasmine.SpyObj; + `, + expected: ` + import type { Mock, MockedObject } from 'vitest'; + + let mySpy: Mock; + let mySpyObj: MockedObject; + `, + }, + { + description: 'should not add an import if no jasmine types are used', + input: `let mySpy: any;`, + expected: `let mySpy: any;`, + }, + { + description: 'should transform jasmine.Any to any', + input: `let myMatcher: jasmine.Any;`, + expected: `let myMatcher: any;`, + }, + { + description: 'should transform jasmine.ObjectContaining to Partial', + input: `let myMatcher: jasmine.ObjectContaining;`, + expected: `let myMatcher: Partial;`, + }, + { + description: 'should transform jasmine.ObjectContaining to object', + input: `let myMatcher: jasmine.ObjectContaining;`, + expected: `let myMatcher: object;`, + }, + { + description: 'should transform jasmine.DoneFn to () => void', + input: `let myDoneFn: jasmine.DoneFn;`, + expected: `let myDoneFn: () => void;`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); +}); 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 fe51e4872a32..5bdb07dd4225 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -8,6 +8,32 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +export function addVitestAutoImport(imports: Set, importName: string): void { + imports.add(importName); +} + +export function getVitestAutoImports(imports: Set): ts.ImportDeclaration | undefined { + if (!imports?.size) { + return undefined; + } + + const importNames = [...imports]; + importNames.sort(); + const importSpecifiers = importNames.map((i) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(i)), + ); + + return ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + ts.SyntaxKind.TypeKeyword, + undefined, + ts.factory.createNamedImports(importSpecifiers), + ), + ts.factory.createStringLiteral('vitest'), + ); +} + export function createViCallExpression( methodName: string, args: readonly ts.Expression[] = [], 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 4b6d32630d1a..3ca9fd3810e7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -22,6 +22,9 @@ export interface RefactorContext { /** The official context from the TypeScript Transformer API. */ readonly tsContext: ts.TransformationContext; + + /** A set of Vitest type imports to be added to the file. */ + readonly pendingVitestImports: Set; } /**