From 84a480c6649b40cc7d61608be3ea02d0f51c5a07 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 matcher transformations to jasmine-to-vitest schematic This commit adds transformers for a wide range of Jasmine matchers. Coverage includes: - Syntactic sugar matchers (toBeTrue, toHaveSize, etc.) - Asymmetric matchers (jasmine.any, jasmine.objectContaining) - Async matchers (expectAsync) - Complex structural rewrites for matchers like 'toHaveBeenCalledOnceWith' and 'arrayWithExactContents'. --- .../jasmine-vitest/test-file-transformer.ts | 42 +- .../transformers/jasmine-matcher.ts | 633 ++++++++++++++++++ .../transformers/jasmine-matcher_spec.ts | 369 ++++++++++ .../jasmine-vitest/utils/refactor-reporter.ts | 4 + 4 files changed, 1046 insertions(+), 2 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_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 3022db629c28..434e50b670bf 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -12,6 +12,18 @@ import { transformFocusedAndSkippedTests, transformPending, } from './transformers/jasmine-lifecycle'; +import { + transformArrayWithExactContents, + transformAsymmetricMatchers, + transformCalledOnceWith, + transformComplexMatchers, + transformExpectAsync, + transformExpectNothing, + transformSyntacticSugarMatchers, + transformToHaveClass, + transformWithContext, + transformtoHaveBeenCalledBefore, +} from './transformers/jasmine-matcher'; import { RefactorContext } from './utils/refactor-context'; import { RefactorReporter } from './utils/refactor-reporter'; @@ -48,21 +60,47 @@ export function transformJasmineToVitest( // Transform the node itself based on its type if (ts.isCallExpression(transformedNode)) { const transformations = [ + transformWithContext, + transformExpectAsync, + transformSyntacticSugarMatchers, transformFocusedAndSkippedTests, + transformComplexMatchers, transformPending, transformDoneCallback, + transformtoHaveBeenCalledBefore, + transformToHaveClass, ]; for (const transformer of transformations) { transformedNode = transformer(transformedNode, refactorCtx); } + } else if (ts.isPropertyAccessExpression(transformedNode)) { + const transformations = [transformAsymmetricMatchers]; + + for (const transformer of transformations) { + transformedNode = transformer(transformedNode, refactorCtx); + } + } else if (ts.isExpressionStatement(transformedNode)) { + const statementTransformers = [ + transformCalledOnceWith, + transformArrayWithExactContents, + transformExpectNothing, + ]; + + for (const transformer of statementTransformers) { + const result = transformer(transformedNode, refactorCtx); + if (result !== transformedNode) { + transformedNode = result; + break; + } + } } // Visit the children of the node to ensure they are transformed if (Array.isArray(transformedNode)) { return transformedNode.map((node) => ts.visitEachChild(node, visitor, context)); } else { - return ts.visitEachChild(transformedNode, visitor, context); + return ts.visitEachChild(transformedNode as ts.Node, visitor, context); } }; @@ -70,7 +108,7 @@ export function transformJasmineToVitest( }; const result = ts.transform(sourceFile, [transformer]); - if (result.transformed[0] === sourceFile) { + if (result.transformed[0] === sourceFile && !reporter.hasTodos) { return content; } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts new file mode 100644 index 000000000000..dd4cb0a122ea --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -0,0 +1,633 @@ +/** + * @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 that migrate Jasmine matchers to their + * Vitest counterparts. It handles a wide range of matchers, including syntactic sugar + * (e.g., `toBeTrue`), asymmetric matchers (e.g., `jasmine.any`), async promise matchers + * (`expectAsync`), and complex matchers that require restructuring, such as + * `toHaveBeenCalledOnceWith` and `arrayWithExactContents`. + */ + +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { 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'; + +const SUGAR_MATCHER_CHANGES = new Map([ + ['toBeTrue', { newName: 'toBe', newArgs: [ts.factory.createTrue()] }], + ['toBeFalse', { newName: 'toBe', newArgs: [ts.factory.createFalse()] }], + ['toBePositiveInfinity', { newName: 'toBe', newArgs: [ts.factory.createIdentifier('Infinity')] }], + [ + 'toBeNegativeInfinity', + { + newName: 'toBe', + newArgs: [ + ts.factory.createPrefixUnaryExpression( + ts.SyntaxKind.MinusToken, + ts.factory.createIdentifier('Infinity'), + ), + ], + }, + ], + ['toHaveSize', { newName: 'toHaveLength' }], +]); + +export function transformSyntacticSugarMatchers( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { + return node; + } + + const pae = node.expression; + const matcherName = pae.name.text; + + if (matcherName === 'toHaveSpyInteractions') { + reporter.recordTodo('toHaveSpyInteractions'); + addTodoComment( + node, + 'Unsupported matcher ".toHaveSpyInteractions()" found. ' + + 'Please migrate this manually by checking the `mock.calls.length` of the individual spies.', + ); + + return node; + } + + if (matcherName === 'toThrowMatching') { + reporter.recordTodo('toThrowMatching'); + addTodoComment( + node, + 'Unsupported matcher ".toThrowMatching()" found. Please migrate this manually.', + ); + + return node; + } + + const mapping = SUGAR_MATCHER_CHANGES.get(matcherName); + + if (mapping) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed matcher ".${matcherName}()" to ".${mapping.newName}()".`, + ); + const newExpression = createPropertyAccess(pae.expression, mapping.newName); + const newArgs = mapping.newArgs ?? [...node.arguments]; + + return ts.factory.updateCallExpression(node, newExpression, node.typeArguments, newArgs); + } + + return node; +} + +const ASYMMETRIC_MATCHER_NAMES: ReadonlyArray = [ + 'anything', + 'any', + 'stringMatching', + 'objectContaining', + 'arrayContaining', + 'stringContaining', +]; + +export function transformAsymmetricMatchers( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'jasmine' + ) { + const matcherName = node.name.text; + if (ASYMMETRIC_MATCHER_NAMES.includes(matcherName)) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed asymmetric matcher \`jasmine.${matcherName}\` to \`expect.${matcherName}\`.`, + ); + + return createPropertyAccess('expect', node.name); + } + } + + return node; +} + +export function transformtoHaveBeenCalledBefore( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.arguments.length !== 1 + ) { + return node; + } + + const pae = node.expression; + const matcherName = pae.name.text; + let isNegated = false; + + let expectExpression = pae.expression; + if (ts.isPropertyAccessExpression(expectExpression) && expectExpression.name.text === 'not') { + isNegated = true; + expectExpression = expectExpression.expression; + } + + if (!ts.isCallExpression(expectExpression) || matcherName !== 'toHaveBeenCalledBefore') { + return node; + } + + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `toHaveBeenCalledBefore` to a Vitest-compatible spy invocation order comparison.', + ); + + const [spyB] = node.arguments; + const [spyA] = expectExpression.arguments; + + const createInvocationOrderAccess = (spyIdentifier: ts.Expression) => { + const mockedSpy = ts.factory.createCallExpression( + createPropertyAccess('vi', 'mocked'), + undefined, + [spyIdentifier], + ); + const mockProperty = createPropertyAccess(mockedSpy, 'mock'); + + return createPropertyAccess(mockProperty, 'invocationCallOrder'); + }; + + const createMinCall = (spyIdentifier: ts.Expression) => { + return ts.factory.createCallExpression(createPropertyAccess('Math', 'min'), undefined, [ + ts.factory.createSpreadElement(createInvocationOrderAccess(spyIdentifier)), + ]); + }; + + const newExpect = createExpectCallExpression([createMinCall(spyA)]); + const newMatcherName = isNegated ? 'toBeGreaterThanOrEqual' : 'toBeLessThan'; + + return ts.factory.createCallExpression( + createPropertyAccess(newExpect, newMatcherName), + undefined, + [createMinCall(spyB)], + ); +} + +export function transformToHaveClass( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.arguments.length !== 1 + ) { + return node; + } + + const pae = node.expression; + const matcherName = pae.name.text; + let isNegated = false; + + let expectExpression = pae.expression; + if (ts.isPropertyAccessExpression(expectExpression) && expectExpression.name.text === 'not') { + isNegated = true; + expectExpression = expectExpression.expression; + } + + if (matcherName !== 'toHaveClass') { + return node; + } + + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.toHaveClass()` to a `classList.contains()` check.', + ); + + const [className] = node.arguments; + const newExpectArgs: ts.Expression[] = []; + + if (ts.isCallExpression(expectExpression)) { + const [element] = expectExpression.arguments; + const classListContains = ts.factory.createCallExpression( + createPropertyAccess(createPropertyAccess(element, 'classList'), 'contains'), + undefined, + [className], + ); + newExpectArgs.push(classListContains); + + // Pass the context message from withContext to the new expect call + if (expectExpression.arguments.length > 1) { + newExpectArgs.push(expectExpression.arguments[1]); + } + } else { + return node; + } + + const newExpect = createExpectCallExpression(newExpectArgs); + const newMatcher = isNegated ? ts.factory.createFalse() : ts.factory.createTrue(); + + return ts.factory.createCallExpression(createPropertyAccess(newExpect, 'toBe'), undefined, [ + newMatcher, + ]); +} + +const ASYNC_MATCHER_CHANGES = new Map< + string, + { + base: 'resolves' | 'rejects'; + matcher: string; + not?: boolean; + keepArgs?: boolean; + } +>([ + ['toBeResolved', { base: 'resolves', matcher: 'toThrow', not: true, keepArgs: false }], + ['toBeResolvedTo', { base: 'resolves', matcher: 'toEqual', keepArgs: true }], + ['toBeRejected', { base: 'rejects', matcher: 'toThrow', keepArgs: false }], + ['toBeRejectedWith', { base: 'rejects', matcher: 'toEqual', keepArgs: true }], + ['toBeRejectedWithError', { base: 'rejects', matcher: 'toThrowError', keepArgs: true }], +]); + +export function transformExpectAsync( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + !ts.isCallExpression(node.expression.expression) + ) { + return node; + } + + const matcherCall = node; + const matcherPae = node.expression; + const expectCall = node.expression.expression; + + if (!ts.isIdentifier(expectCall.expression) || expectCall.expression.text !== 'expectAsync') { + return node; + } + + const matcherName = ts.isIdentifier(matcherPae.name) ? matcherPae.name.text : undefined; + const mapping = matcherName ? ASYNC_MATCHER_CHANGES.get(matcherName) : undefined; + + if (mapping) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed \`expectAsync(...).${matcherName}\` to \`expect(...).${mapping.base}.${mapping.matcher}\`.`, + ); + const newExpectCall = createExpectCallExpression([expectCall.arguments[0]]); + let newMatcherChain: ts.Expression = createPropertyAccess(newExpectCall, mapping.base); + + if (mapping.not) { + newMatcherChain = createPropertyAccess(newMatcherChain, 'not'); + } + newMatcherChain = createPropertyAccess(newMatcherChain, mapping.matcher); + + const newMatcherArgs = mapping.keepArgs ? [...matcherCall.arguments] : []; + + return ts.factory.createCallExpression(newMatcherChain, undefined, newMatcherArgs); + } + + if (matcherName) { + if (matcherName === 'toBePending') { + reporter.recordTodo('toBePending'); + addTodoComment( + node, + 'Unsupported matcher ".toBePending()" found. Vitest does not have a direct equivalent. ' + + 'Please migrate this manually, for example by using `Promise.race` to check if the promise settles within a short timeout.', + ); + } else { + reporter.recordTodo('unsupported-expect-async-matcher'); + addTodoComment( + node, + `Unsupported expectAsync matcher ".${matcherName}()" found. Please migrate this manually.`, + ); + } + } + + return node; +} + +export function transformComplexMatchers( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== 'toEqual' || + node.arguments.length !== 1 + ) { + return node; + } + + const argument = node.arguments[0]; + const jasmineMatcherName = getJasmineMethodName(argument); + + if (!jasmineMatcherName) { + return node; + } + + const expectCall = node.expression.expression; + + let newMatcherName: string | undefined; + let newArgs: ts.Expression[] | undefined; + let negate = false; + + switch (jasmineMatcherName) { + case 'truthy': + newMatcherName = 'toBeTruthy'; + break; + case 'falsy': + newMatcherName = 'toBeFalsy'; + break; + case 'empty': + newMatcherName = 'toHaveLength'; + newArgs = [ts.factory.createNumericLiteral(0)]; + break; + case 'notEmpty': + newMatcherName = 'toHaveLength'; + newArgs = [ts.factory.createNumericLiteral(0)]; + negate = true; + break; + case 'is': + newMatcherName = 'toBe'; + if (ts.isCallExpression(argument)) { + newArgs = [...argument.arguments]; + } + break; + } + + if (newMatcherName) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed \`.toEqual(jasmine.${jasmineMatcherName}())\` to \`.${newMatcherName}()\`.`, + ); + let expectExpression = expectCall; + + // Handle cases like `expect(...).not.toEqual(jasmine.notEmpty())` + if (ts.isPropertyAccessExpression(expectCall) && expectCall.name.text === 'not') { + // The original expression was negated, so flip the negate flag + negate = !negate; + // Use the expression before the `.not` + expectExpression = expectCall.expression; + } + + if (negate) { + expectExpression = createPropertyAccess(expectExpression, 'not'); + } + + const newExpression = createPropertyAccess(expectExpression, newMatcherName); + + return ts.factory.createCallExpression(newExpression, undefined, newArgs ?? []); + } + + return node; +} + +export function transformArrayWithExactContents( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node | readonly ts.Node[] { + if ( + !ts.isExpressionStatement(node) || + !ts.isCallExpression(node.expression) || + !ts.isPropertyAccessExpression(node.expression.expression) || + node.expression.expression.name.text !== 'toEqual' || + node.expression.arguments.length !== 1 + ) { + return node; + } + + const argument = node.expression.arguments[0]; + if ( + !isJasmineCallExpression(argument, 'arrayWithExactContents') || + argument.arguments.length !== 1 + ) { + return node; + } + + if (!ts.isArrayLiteralExpression(argument.arguments[0])) { + reporter.recordTodo('arrayWithExactContents-dynamic-variable'); + addTodoComment( + node, + 'Cannot transform jasmine.arrayWithExactContents with a dynamic variable. Please migrate this manually.', + ); + + return node; + } + + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `jasmine.arrayWithExactContents()` to `.toHaveLength()` and `.toEqual(expect.arrayContaining())`.', + ); + + const expectCall = node.expression.expression.expression; + const arrayLiteral = argument.arguments[0]; + + const lengthCall = ts.factory.createCallExpression( + createPropertyAccess(expectCall, 'toHaveLength'), + undefined, + [ts.factory.createNumericLiteral(arrayLiteral.elements.length)], + ); + + const containingCall = ts.factory.createCallExpression( + createPropertyAccess(expectCall, 'toEqual'), + undefined, + [ + ts.factory.createCallExpression( + createPropertyAccess('expect', 'arrayContaining'), + undefined, + [arrayLiteral], + ), + ], + ); + + return [ + ts.factory.createExpressionStatement(lengthCall), + ts.factory.createExpressionStatement(containingCall), + ]; +} + +export function transformCalledOnceWith( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node | readonly ts.Node[] { + if (!ts.isExpressionStatement(node)) { + return node; + } + + const call = node.expression; + if ( + !ts.isCallExpression(call) || + !ts.isPropertyAccessExpression(call.expression) || + call.expression.name.text !== 'toHaveBeenCalledOnceWith' + ) { + return node; + } + + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.toHaveBeenCalledOnceWith()` to `.toHaveBeenCalledTimes(1)` and `.toHaveBeenCalledWith()`.', + ); + + const expectCall = call.expression.expression; + const args = call.arguments; + + const timesCall = ts.factory.createCallExpression( + createPropertyAccess(expectCall, 'toHaveBeenCalledTimes'), + undefined, + [ts.factory.createNumericLiteral(1)], + ); + + const withCall = ts.factory.createCallExpression( + createPropertyAccess(expectCall, 'toHaveBeenCalledWith'), + undefined, + args, + ); + + return [ + ts.factory.createExpressionStatement(timesCall), + ts.factory.createExpressionStatement(withCall), + ]; +} + +export function transformWithContext( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { + return node; + } + + // Traverse the chain of property access expressions to find the .withContext() call + let currentExpression: ts.Expression = node.expression; + const propertyChain: ts.Identifier[] = []; + + while (ts.isPropertyAccessExpression(currentExpression)) { + if (!ts.isIdentifier(currentExpression.name)) { + // Break if we encounter a private identifier or something else unexpected + return node; + } + propertyChain.push(currentExpression.name); + currentExpression = currentExpression.expression; + } + + const withContextCall = currentExpression; + // Check if we found a .withContext() call + if ( + !ts.isCallExpression(withContextCall) || + !ts.isPropertyAccessExpression(withContextCall.expression) || + !ts.isIdentifier(withContextCall.expression.name) || + withContextCall.expression.name.text !== 'withContext' + ) { + return node; + } + + reporter.reportTransformation( + sourceFile, + withContextCall, + 'Transformed `.withContext()` to the `expect(..., message)` syntax.', + ); + + const expectCall = withContextCall.expression.expression; + + if ( + !ts.isCallExpression(expectCall) || + !ts.isIdentifier(expectCall.expression) || + expectCall.expression.text !== 'expect' + ) { + return node; + } + + const contextMessage = withContextCall.arguments[0]; + if (!contextMessage) { + // No message provided, so unwrap the .withContext() call. + let newChain: ts.Expression = expectCall; + for (let i = propertyChain.length - 1; i >= 0; i--) { + newChain = ts.factory.createPropertyAccessExpression(newChain, propertyChain[i]); + } + + return ts.factory.updateCallExpression(node, newChain, node.typeArguments, node.arguments); + } + + const newExpectArgs = [...expectCall.arguments, contextMessage]; + const newExpectCall = ts.factory.updateCallExpression( + expectCall, + expectCall.expression, + expectCall.typeArguments, + newExpectArgs, + ); + + // Rebuild the property access chain + let newExpression: ts.Expression = newExpectCall; + for (let i = propertyChain.length - 1; i >= 0; i--) { + newExpression = ts.factory.createPropertyAccessExpression(newExpression, propertyChain[i]); + } + + return ts.factory.updateCallExpression(node, newExpression, node.typeArguments, node.arguments); +} + +export function transformExpectNothing( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if (!ts.isExpressionStatement(node)) { + return node; + } + + const call = node.expression; + if ( + !ts.isCallExpression(call) || + !ts.isPropertyAccessExpression(call.expression) || + !ts.isIdentifier(call.expression.name) || + call.expression.name.text !== 'nothing' + ) { + return node; + } + + const expectCall = call.expression.expression; + if ( + !ts.isCallExpression(expectCall) || + !ts.isIdentifier(expectCall.expression) || + expectCall.expression.text !== 'expect' || + expectCall.arguments.length > 0 + ) { + return node; + } + + // The statement is `expect().nothing()`, which can be removed. + const replacement = ts.factory.createEmptyStatement(); + const originalText = node.getFullText().trim(); + + reporter.reportTransformation(sourceFile, node, 'Removed `expect().nothing()` statement.'); + reporter.recordTodo('expect-nothing'); + addTodoComment( + replacement, + 'expect().nothing() has been removed because it is redundant in Vitest. Tests without assertions pass by default.', + ); + ts.addSyntheticLeadingComment( + replacement, + ts.SyntaxKind.SingleLineCommentTrivia, + ` ${originalText}`, + true, + ); + + return replacement; +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts new file mode 100644 index 000000000000..c6d1f431a54e --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts @@ -0,0 +1,369 @@ +/** + * @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('transformAsymmetricMatchers', () => { + const testCases = [ + { + description: 'should transform jasmine.any(String) to expect.any(String)', + input: `expect(foo).toEqual(jasmine.any(String));`, + expected: `expect(foo).toEqual(expect.any(String));`, + }, + { + description: + 'should transform jasmine.objectContaining(...) to expect.objectContaining(...)', + input: `expect(foo).toEqual(jasmine.objectContaining({ bar: 'baz' }));`, + expected: `expect(foo).toEqual(expect.objectContaining({ bar: 'baz' }));`, + }, + { + description: 'should transform jasmine.anything() to expect.anything()', + input: `expect(foo).toEqual(jasmine.anything());`, + expected: `expect(foo).toEqual(expect.anything());`, + }, + { + description: 'should transform jasmine.stringMatching(...) to expect.stringMatching(...)', + input: `expect(foo).toEqual(jasmine.stringMatching(/some-pattern/));`, + expected: `expect(foo).toEqual(expect.stringMatching(/some-pattern/));`, + }, + { + description: 'should transform jasmine.arrayContaining(...) to expect.arrayContaining(...)', + input: `expect(foo).toEqual(jasmine.arrayContaining(['a']));`, + expected: `expect(foo).toEqual(expect.arrayContaining(['a']));`, + }, + { + description: + 'should transform jasmine.stringContaining(...) to expect.stringContaining(...)', + input: `expect(foo).toEqual(jasmine.stringContaining('substring'));`, + expected: `expect(foo).toEqual(expect.stringContaining('substring'));`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformExpectAsync', () => { + const testCases = [ + { + description: 'should transform expectAsync(...).toBeResolved()', + input: `await expectAsync(myPromise).toBeResolved();`, + expected: `await expect(myPromise).resolves.not.toThrow();`, + }, + { + description: 'should transform expectAsync(...).toBeResolvedTo(value)', + input: `await expectAsync(myPromise).toBeResolvedTo(42);`, + expected: `await expect(myPromise).resolves.toEqual(42);`, + }, + { + description: 'should transform expectAsync(...).toBeRejected()', + input: `await expectAsync(myPromise).toBeRejected();`, + expected: `await expect(myPromise).rejects.toThrow();`, + }, + { + description: 'should transform expectAsync(...).toBeRejectedWith(error)', + input: `await expectAsync(myPromise).toBeRejectedWith('Error');`, + expected: `await expect(myPromise).rejects.toEqual('Error');`, + }, + { + description: 'should transform expectAsync(...).toBeRejectedWithError(ErrorClass, message)', + input: `await expectAsync(myPromise).toBeRejectedWithError(TypeError, 'Failed');`, + expected: `await expect(myPromise).rejects.toThrowError(TypeError, 'Failed');`, + }, + { + description: 'should add a TODO for an unknown expectAsync matcher', + input: `await expectAsync(myPromise).toBeSomethingElse();`, + expected: ` + // TODO: vitest-migration: Unsupported expectAsync matcher ".toBeSomethingElse()" found. Please migrate this manually. + await expectAsync(myPromise).toBeSomethingElse(); + `, + }, + { + description: 'should add a specific TODO for toBePending', + input: `await expectAsync(myPromise).toBePending();`, + /* eslint-disable max-len */ + expected: ` + // TODO: vitest-migration: Unsupported matcher ".toBePending()" found. Vitest does not have a direct equivalent. Please migrate this manually, for example by using \`Promise.race\` to check if the promise settles within a short timeout. + await expectAsync(myPromise).toBePending(); + `, + /* eslint-enable max-len */ + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformCalledOnceWith', () => { + const testCases = [ + { + description: 'should transform toHaveBeenCalledOnceWith(...) into two separate calls', + input: `expect(mySpy).toHaveBeenCalledOnceWith('foo', 'bar');`, + expected: ` + expect(mySpy).toHaveBeenCalledTimes(1); + expect(mySpy).toHaveBeenCalledWith('foo', 'bar'); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformSyntacticSugarMatchers', () => { + const testCases = [ + { + description: 'should transform toBeTrue() to toBe(true)', + input: `expect(value).toBeTrue();`, + expected: `expect(value).toBe(true);`, + }, + { + description: 'should transform toBeFalse() to toBe(false)', + input: `expect(value).toBeFalse();`, + expected: `expect(value).toBe(false);`, + }, + { + description: 'should transform toBePositiveInfinity() to toBe(Infinity)', + input: `expect(value).toBePositiveInfinity();`, + expected: `expect(value).toBe(Infinity);`, + }, + { + description: 'should transform toBeNegativeInfinity() to toBe(-Infinity)', + input: `expect(value).toBeNegativeInfinity();`, + expected: `expect(value).toBe(-Infinity);`, + }, + { + description: 'should transform toHaveSize(number) to toHaveLength(number)', + input: `expect(myArray).toHaveSize(3);`, + expected: `expect(myArray).toHaveLength(3);`, + }, + { + description: 'should add a TODO for toThrowMatching', + input: `expect(() => {}).toThrowMatching((e) => e.message === 'foo');`, + expected: `// TODO: vitest-migration: Unsupported matcher ".toThrowMatching()" found. Please migrate this manually. +expect(() => {}).toThrowMatching((e) => e.message === 'foo');`, + }, + { + description: 'should add a TODO for toHaveSpyInteractions', + input: `expect(mySpyObj).toHaveSpyInteractions();`, + // eslint-disable-next-line max-len + expected: `// TODO: vitest-migration: Unsupported matcher ".toHaveSpyInteractions()" found. Please migrate this manually by checking the \`mock.calls.length\` of the individual spies. +expect(mySpyObj).toHaveSpyInteractions();`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformComplexMatchers', () => { + const testCases = [ + { + description: 'should transform toEqual(jasmine.truthy()) to toBeTruthy()', + input: `expect(value).toEqual(jasmine.truthy());`, + expected: `expect(value).toBeTruthy();`, + }, + { + description: 'should transform toEqual(jasmine.falsy()) to toBeFalsy()', + input: `expect(value).toEqual(jasmine.falsy());`, + expected: `expect(value).toBeFalsy();`, + }, + { + description: 'should transform toEqual(jasmine.empty()) to toHaveLength(0)', + input: `expect([]).toEqual(jasmine.empty());`, + expected: `expect([]).toHaveLength(0);`, + }, + { + description: 'should transform not.toEqual(jasmine.empty()) to not.toHaveLength(0)', + input: `expect([1]).not.toEqual(jasmine.empty());`, + expected: `expect([1]).not.toHaveLength(0);`, + }, + { + description: 'should transform toEqual(jasmine.notEmpty()) to not.toHaveLength(0)', + input: `expect([1]).toEqual(jasmine.notEmpty());`, + expected: `expect([1]).not.toHaveLength(0);`, + }, + { + description: 'should transform not.toEqual(jasmine.notEmpty()) to toHaveLength(0)', + input: `expect([]).not.toEqual(jasmine.notEmpty());`, + expected: `expect([]).toHaveLength(0);`, + }, + { + description: 'should transform toEqual(jasmine.is()) to toBe()', + input: `expect(value).toEqual(jasmine.is(otherValue));`, + expected: `expect(value).toBe(otherValue);`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformArrayWithExactContents', () => { + const testCases = [ + { + description: 'should transform toEqual(jasmine.arrayWithExactContents()) into two calls', + input: `expect(myArray).toEqual(jasmine.arrayWithExactContents(['a', 'b']));`, + expected: ` + expect(myArray).toHaveLength(2); + expect(myArray).toEqual(expect.arrayContaining(['a', 'b'])); + `, + }, + { + description: + 'should transform toEqual(jasmine.arrayWithExactContents()) with asymmetric matchers', + input: `expect(myArray).toEqual(jasmine.arrayWithExactContents([jasmine.any(Number), 'a']));`, + expected: ` + expect(myArray).toHaveLength(2); + expect(myArray).toEqual(expect.arrayContaining([expect.any(Number), 'a'])); + `, + }, + { + description: + 'should add a TODO for toEqual(jasmine.arrayWithExactContents()) with a variable', + input: `expect(myArray).toEqual(jasmine.arrayWithExactContents(someOtherArray));`, + expected: ` + // TODO: vitest-migration: Cannot transform jasmine.arrayWithExactContents with a dynamic variable. Please migrate this manually. + expect(myArray).toEqual(jasmine.arrayWithExactContents(someOtherArray)); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformExpectNothing', () => { + const testCases = [ + { + description: 'should remove expect().nothing() and add a comment', + input: ` + it('should be a passing test', () => { + expect().nothing(); + }); + `, + /* eslint-disable max-len */ + expected: ` + it('should be a passing test', () => { + // TODO: vitest-migration: expect().nothing() has been removed because it is redundant in Vitest. Tests without assertions pass by default. + // expect().nothing(); + }); + `, + /* eslint-enable max-len */ + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformWithContext', () => { + const testCases = [ + { + description: 'should transform .withContext() to an expect message', + input: `expect(value).withContext('It should be true').toBe(true);`, + expected: `expect(value, 'It should be true').toBe(true);`, + }, + { + description: 'should handle chained matchers', + input: `expect(value).withContext('It should not be false').not.toBe(false);`, + expected: `expect(value, 'It should not be false').not.toBe(false);`, + }, + { + description: 'should handle .withContext() with no arguments by removing it', + input: `expect(value).withContext().toBe(true);`, + expected: `expect(value).toBe(true);`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformtoHaveBeenCalledBefore', () => { + const testCases = [ + { + description: 'should transform toHaveBeenCalledBefore', + input: `expect(spyA).toHaveBeenCalledBefore(spyB);`, + // eslint-disable-next-line max-len + expected: `expect(Math.min(...vi.mocked(spyA).mock.invocationCallOrder)).toBeLessThan(Math.min(...vi.mocked(spyB).mock.invocationCallOrder));`, + }, + { + description: 'should transform not.toHaveBeenCalledBefore', + input: `expect(spyA).not.toHaveBeenCalledBefore(spyB);`, + // eslint-disable-next-line max-len + expected: `expect(Math.min(...vi.mocked(spyA).mock.invocationCallOrder)).toBeGreaterThanOrEqual(Math.min(...vi.mocked(spyB).mock.invocationCallOrder));`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); + + describe('transformToHaveClass', () => { + const testCases = [ + { + description: 'should transform toHaveClass', + input: `expect(element).toHaveClass('my-class');`, + expected: `expect(element.classList.contains('my-class')).toBe(true);`, + }, + { + description: 'should transform not.toHaveClass', + input: `expect(element).not.toHaveClass('my-class');`, + expected: `expect(element.classList.contains('my-class')).toBe(false);`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts index 0e99631a0f8b..55d235e96c25 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts @@ -17,6 +17,10 @@ export class RefactorReporter { constructor(private logger: logging.LoggerApi) {} + get hasTodos(): boolean { + return this.todos.size > 0; + } + incrementScannedFiles(): void { this.filesScanned++; }