From 8e632bd927593f65578577bde8c86666724ce4b2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:41:49 -0400 Subject: [PATCH] feat(@schematics/angular): add misc transformations to jasmine-to-vitest schematic This commit adds transformers for miscellaneous Jasmine APIs and handles unsupported features. Coverage includes: - Timer mocks (jasmine.clock) - The fail() function - jasmine.DEFAULT_TIMEOUT_INTERVAL It also includes the logic to identify and add TODO comments for any unsupported Jasmine APIs, ensuring a safe migration. --- .../jasmine-vitest/test-file-transformer.ts | 22 +- .../transformers/jasmine-misc.ts | 250 ++++++++++++++++++ .../transformers/jasmine-misc_spec.ts | 230 ++++++++++++++++ 3 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts 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 816c0f6326da..b23dc3583b6c 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -24,6 +24,14 @@ import { transformWithContext, transformtoHaveBeenCalledBefore, } from './transformers/jasmine-matcher'; +import { + transformDefaultTimeoutInterval, + transformFail, + transformGlobalFunctions, + transformTimerMocks, + transformUnknownJasmineProperties, + transformUnsupportedJasmineCalls, +} from './transformers/jasmine-misc'; import { transformCreateSpyObj, transformSpies, @@ -80,13 +88,23 @@ export function transformJasmineToVitest( transformDoneCallback, transformtoHaveBeenCalledBefore, transformToHaveClass, + transformTimerMocks, + transformFocusedAndSkippedTests, + transformPending, + transformDoneCallback, + transformGlobalFunctions, + transformUnsupportedJasmineCalls, ]; for (const transformer of transformations) { transformedNode = transformer(transformedNode, refactorCtx); } } else if (ts.isPropertyAccessExpression(transformedNode)) { - const transformations = [transformAsymmetricMatchers, transformSpyCallInspection]; + const transformations = [ + transformAsymmetricMatchers, + transformSpyCallInspection, + transformUnknownJasmineProperties, + ]; for (const transformer of transformations) { transformedNode = transformer(transformedNode, refactorCtx); } @@ -95,6 +113,8 @@ export function transformJasmineToVitest( transformCalledOnceWith, transformArrayWithExactContents, transformExpectNothing, + transformFail, + transformDefaultTimeoutInterval, ]; for (const transformer of statementTransformers) { diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts new file mode 100644 index 000000000000..effcf506e1c2 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.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 + */ + +/** + * @fileoverview This file contains transformers for miscellaneous Jasmine APIs that don't + * fit into other categories. This includes timer mocks (`jasmine.clock`), the `fail()` + * function, and configuration settings like `jasmine.DEFAULT_TIMEOUT_INTERVAL`. It also + * includes logic to identify and add TODO comments for unsupported Jasmine features. + */ + +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { 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 transformTimerMocks( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + !ts.isIdentifier(node.expression.name) + ) { + return node; + } + + const pae = node.expression; + const clockCall = pae.expression; + if (!isJasmineCallExpression(clockCall, 'clock')) { + return node; + } + + let newMethodName: string | undefined; + switch (pae.name.text) { + case 'install': + newMethodName = 'useFakeTimers'; + break; + case 'tick': + newMethodName = 'advanceTimersByTime'; + break; + case 'uninstall': + newMethodName = 'useRealTimers'; + break; + case 'mockDate': + newMethodName = 'setSystemTime'; + break; + } + + if (newMethodName) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed \`jasmine.clock().${pae.name.text}\` to \`vi.${newMethodName}\`.`, + ); + const newArgs = newMethodName === 'useFakeTimers' ? [] : node.arguments; + + return createViCallExpression(newMethodName, newArgs); + } + + return node; +} + +export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorContext): ts.Node { + if ( + ts.isExpressionStatement(node) && + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'fail' + ) { + reporter.reportTransformation(sourceFile, node, 'Transformed `fail()` to `throw new Error()`.'); + const reason = node.expression.arguments[0]; + + return ts.factory.createThrowStatement( + ts.factory.createNewExpression( + ts.factory.createIdentifier('Error'), + undefined, + reason ? [reason] : [], + ), + ); + } + + return node; +} + +export function transformDefaultTimeoutInterval( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + ts.isExpressionStatement(node) && + ts.isBinaryExpression(node.expression) && + node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + const assignment = node.expression; + if ( + ts.isPropertyAccessExpression(assignment.left) && + ts.isIdentifier(assignment.left.expression) && + assignment.left.expression.text === 'jasmine' && + assignment.left.name.text === 'DEFAULT_TIMEOUT_INTERVAL' + ) { + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `jasmine.DEFAULT_TIMEOUT_INTERVAL` to `vi.setConfig()`.', + ); + const timeoutValue = assignment.right; + const setConfigCall = createViCallExpression('setConfig', [ + ts.factory.createObjectLiteralExpression( + [ts.factory.createPropertyAssignment('testTimeout', timeoutValue)], + false, + ), + ]); + + return ts.factory.createExpressionStatement(setConfigCall); + } + } + + return node; +} + +export function transformGlobalFunctions( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + (node.expression.text === 'setSpecProperty' || node.expression.text === 'setSuiteProperty') + ) { + const functionName = node.expression.text; + reporter.reportTransformation( + sourceFile, + node, + `Found unsupported global function \`${functionName}\`.`, + ); + reporter.recordTodo(functionName); + addTodoComment( + node, + `Unsupported global function \`${functionName}\` found. This function is used for custom reporters in Jasmine ` + + 'and has no direct equivalent in Vitest.', + ); + } + + return node; +} + +const JASMINE_UNSUPPORTED_CALLS = new Map([ + [ + 'addMatchers', + 'jasmine.addMatchers is not supported. Please manually migrate to expect.extend().', + ], + [ + 'addCustomEqualityTester', + 'jasmine.addCustomEqualityTester is not supported. Please manually migrate to expect.addEqualityTesters().', + ], + [ + 'mapContaining', + 'jasmine.mapContaining is not supported. Vitest does not have a built-in matcher for Maps.' + + ' Please manually assert the contents of the Map.', + ], + [ + 'setContaining', + 'jasmine.setContaining is not supported. Vitest does not have a built-in matcher for Sets.' + + ' Please manually assert the contents of the Set.', + ], +]); + +export function transformUnsupportedJasmineCalls( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + const methodName = getJasmineMethodName(node); + if (!methodName) { + return node; + } + + const message = JASMINE_UNSUPPORTED_CALLS.get(methodName); + if (message) { + reporter.reportTransformation( + sourceFile, + node, + `Found unsupported call \`jasmine.${methodName}\`.`, + ); + reporter.recordTodo(methodName); + addTodoComment(node, message); + } + + return node; +} + +// If any additional properties are added to transforms, they should also be added to this list. +const HANDLED_JASMINE_PROPERTIES = new Set([ + // Spies + 'createSpy', + 'createSpyObj', + 'spyOnAllFunctions', + // Clock + 'clock', + // Matchers + 'any', + 'anything', + 'stringMatching', + 'objectContaining', + 'arrayContaining', + 'arrayWithExactContents', + 'truthy', + 'falsy', + 'empty', + 'notEmpty', + 'mapContaining', + 'setContaining', + // Other + 'DEFAULT_TIMEOUT_INTERVAL', + 'addMatchers', + 'addCustomEqualityTester', +]); + +export function transformUnknownJasmineProperties( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'jasmine' + ) { + const propName = node.name.text; + if (!HANDLED_JASMINE_PROPERTIES.has(propName)) { + reporter.reportTransformation( + sourceFile, + node, + `Found unknown jasmine property \`jasmine.${propName}\`.`, + ); + reporter.recordTodo(`unknown-jasmine-property: ${propName}`); + addTodoComment( + node, + `Unsupported jasmine property "${propName}" found. Please migrate this manually.`, + ); + } + } + + return node; +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts new file mode 100644 index 000000000000..6b2a92b57fd1 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts @@ -0,0 +1,230 @@ +/** + * @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 { logging } from '@angular-devkit/core'; +import { format } from 'prettier'; +import { transformJasmineToVitest } from '../test-file-transformer'; +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 formattedTransformed = await format(transformed, { parser: 'typescript' }); + const formattedExpected = await format(expected, { parser: 'typescript' }); + + expect(formattedTransformed).toBe(formattedExpected); +} + +describe('Jasmine to Vitest Transformer', () => { + describe('transformTimerMocks', () => { + const testCases = [ + { + description: 'should transform jasmine.clock().install() to vi.useFakeTimers()', + input: `jasmine.clock().install();`, + expected: `vi.useFakeTimers();`, + }, + { + description: 'should transform jasmine.clock().tick(100) to vi.advanceTimersByTime(100)', + input: `jasmine.clock().tick(100);`, + expected: `vi.advanceTimersByTime(100);`, + }, + { + description: 'should transform jasmine.clock().uninstall() to vi.useRealTimers()', + input: `jasmine.clock().uninstall();`, + expected: `vi.useRealTimers();`, + }, + { + description: 'should transform jasmine.clock().mockDate(date) to vi.setSystemTime(date)', + input: `jasmine.clock().mockDate(new Date('2025-01-01'));`, + expected: `vi.setSystemTime(new Date('2025-01-01'));`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformFail', () => { + const testCases = [ + { + description: 'should transform fail() to throw new Error()', + input: `fail('This should not happen');`, + expected: `throw new Error('This should not happen');`, + }, + { + description: 'should transform fail() without a message to throw new Error()', + input: `fail();`, + expected: `throw new Error();`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformDefaultTimeoutInterval', () => { + const testCases = [ + { + description: 'should transform jasmine.DEFAULT_TIMEOUT_INTERVAL', + input: `jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;`, + expected: `vi.setConfig({ testTimeout: 10000 });`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformAddMatchers', () => { + const testCases = [ + { + description: 'should add a TODO for jasmine.addMatchers', + input: ` + jasmine.addMatchers({ + toBeDivisibleByTwo: function() { + return { + compare: function(actual) { + return { + pass: actual % 2 === 0 + }; + } + }; + } + }); + `, + expected: ` + // TODO: vitest-migration: jasmine.addMatchers is not supported. Please manually migrate to expect.extend(). + jasmine.addMatchers({ + toBeDivisibleByTwo: function () { + return { + compare: function (actual) { + return { + pass: actual % 2 === 0, + }; + }, + }; + }, + }); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformAddCustomEqualityTester', () => { + const testCases = [ + { + description: 'should add a TODO for jasmine.addCustomEqualityTester', + input: ` + jasmine.addCustomEqualityTester((a, b) => { + return a.toString() === b.toString(); + }); + `, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: jasmine.addCustomEqualityTester is not supported. Please manually migrate to expect.addEqualityTesters(). + jasmine.addCustomEqualityTester((a, b) => { + return a.toString() === b.toString(); + }); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformUnknownJasmineProperties', () => { + const testCases = [ + { + description: 'should add a TODO for an unknown jasmine property', + input: `const env = jasmine.getEnv();`, + expected: `// TODO: vitest-migration: Unsupported jasmine property "getEnv" found. Please migrate this manually. +const env = jasmine.getEnv();`, + }, + { + description: 'should not add a TODO for a known jasmine property', + input: `const spy = jasmine.createSpy();`, + expected: `const spy = vi.fn();`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformGlobalFunctions', () => { + const testCases = [ + { + description: 'should add a TODO for setSpecProperty', + input: `setSpecProperty('myKey', 'myValue');`, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: Unsupported global function \`setSpecProperty\` found. This function is used for custom reporters in Jasmine and has no direct equivalent in Vitest. +setSpecProperty('myKey', 'myValue');`, + }, + { + description: 'should add a TODO for setSuiteProperty', + input: `setSuiteProperty('myKey', 'myValue');`, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: Unsupported global function \`setSuiteProperty\` found. This function is used for custom reporters in Jasmine and has no direct equivalent in Vitest. +setSuiteProperty('myKey', 'myValue');`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformUnsupportedJasmineCalls', () => { + const testCases = [ + { + description: 'should add a TODO for jasmine.mapContaining', + input: `expect(myMap).toEqual(jasmine.mapContaining(new Map()));`, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: jasmine.mapContaining is not supported. Vitest does not have a built-in matcher for Maps. Please manually assert the contents of the Map. +expect(myMap).toEqual(jasmine.mapContaining(new Map()));`, + }, + { + description: 'should add a TODO for jasmine.setContaining', + input: `expect(mySet).toEqual(jasmine.setContaining(new Set()));`, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: jasmine.setContaining is not supported. Vitest does not have a built-in matcher for Sets. Please manually assert the contents of the Set. +expect(mySet).toEqual(jasmine.setContaining(new Set()));`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); +});