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 d037ed5c7f08..86fc21d7652e 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 @@ -288,8 +288,8 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { }); it('should handle spy call order', () => { - const spyA = vi.fn(); - const spyB = vi.fn(); + const spyA = vi.fn().mockName('spyA'); + const spyB = vi.fn().mockName('spyB'); spyA(); spyB(); expect(Math.min(...vi.mocked(spyA).mock.invocationCallOrder)).toBeLessThan(Math.min(...vi.mocked(spyB).mock.invocationCallOrder)); @@ -387,7 +387,7 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { }); it('should handle spies throwing errors', () => { - const spy = vi.fn().mockImplementation(() => { throw new Error('Test Error') }); + const spy = vi.fn().mockName('mySpy').mockImplementation(() => { throw new Error('Test Error') }); expect(() => spy()).toThrowError('Test Error'); }); }); 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 db225a0a4473..ef4e894e8194 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -27,19 +27,21 @@ import { transformExpectAsync, transformExpectNothing, transformSyntacticSugarMatchers, + transformToBeNullish, + transformToHaveBeenCalledBefore, transformToHaveClass, transformWithContext, - transformtoHaveBeenCalledBefore, } from './transformers/jasmine-matcher'; import { - transformDefaultTimeoutInterval, transformFail, - transformGlobalFunctions, + transformJasmineMembers, transformTimerMocks, transformUnknownJasmineProperties, + transformUnsupportedGlobalFunctions, transformUnsupportedJasmineCalls, } from './transformers/jasmine-misc'; import { + transformCreateSpy, transformCreateSpyObj, transformSpies, transformSpyCallInspection, @@ -116,16 +118,18 @@ const callExpressionTransformers = [ transformSyntacticSugarMatchers, transformComplexMatchers, transformSpies, + transformCreateSpy, transformCreateSpyObj, transformSpyReset, transformSpyCallInspection, - transformtoHaveBeenCalledBefore, + transformToHaveBeenCalledBefore, transformToHaveClass, + transformToBeNullish, // **Stage 3: Global Functions & Cleanup** // These handle global Jasmine functions and catch-alls for unsupported APIs. transformTimerMocks, - transformGlobalFunctions, + transformUnsupportedGlobalFunctions, transformUnsupportedJasmineCalls, ]; @@ -149,7 +153,7 @@ const expressionStatementTransformers = [ transformArrayWithExactContents, transformExpectNothing, transformFail, - transformDefaultTimeoutInterval, + transformJasmineMembers, ]; /** diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_spec.ts index d9f78c471e18..abaf1de4456b 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_spec.ts @@ -28,7 +28,7 @@ describe('Jasmine to Vitest Transformer - Nested Transformations', () => { await expectAsync(service.myProp).toBeResolvedTo(42); `, expected: ` - vi.spyOn(service, 'myProp', 'get').mockReturnValue(Promise.resolve(42)); + vi.spyOn(service, 'myProp', 'get').mockResolvedValue(42); await expect(service.myProp).resolves.toEqual(42); `, }, 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 05c137100271..2d258e83d6f5 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -121,7 +121,7 @@ export function transformAsymmetricMatchers( return node; } -export function transformtoHaveBeenCalledBefore( +export function transformToHaveBeenCalledBefore( node: ts.Node, { sourceFile, reporter }: RefactorContext, ): ts.Node { @@ -205,7 +205,7 @@ export function transformToHaveClass( expectExpression = expectExpression.expression; } - if (matcherName !== 'toHaveClass') { + if (matcherName !== 'toHaveClass' || !ts.isCallExpression(expectExpression)) { return node; } @@ -218,21 +218,17 @@ export function transformToHaveClass( 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); + 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; + // Pass the context message from withContext to the new expect call + if (expectExpression.arguments.length > 1) { + newExpectArgs.push(expectExpression.arguments[1]); } const newExpect = createExpectCallExpression(newExpectArgs); @@ -626,3 +622,63 @@ export function transformExpectNothing( return replacement; } + +export function transformToBeNullish( + node: ts.Node, + { sourceFile, reporter }: RefactorContext, +): ts.Node { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.arguments.length !== 0 + ) { + 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 !== 'toBeNullish' || !ts.isCallExpression(expectExpression)) { + return node; + } + + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.toBeNullish()` to a `element === null || element === undefined` check.', + ); + + const element = expectExpression.arguments[0]; + + const nullCheck = ts.factory.createBinaryExpression( + element, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.factory.createNull(), + ); + + const undefinedCheck = ts.factory.createBinaryExpression( + element, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.factory.createIdentifier('undefined'), + ); + + const fullExpression = ts.factory.createBinaryExpression( + nullCheck, + ts.SyntaxKind.BarBarToken, + undefinedCheck, + ); + + const newExpect = createExpectCallExpression([fullExpression]); + const newMatcher = isNegated ? ts.factory.createFalse() : ts.factory.createTrue(); + + return ts.factory.createCallExpression(createPropertyAccess(newExpect, 'toBe'), undefined, [ + newMatcher, + ]); +} 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 index c5e9f8d25a65..3d687fc84b56 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts @@ -356,3 +356,24 @@ describe('transformToHaveClass', () => { }); }); }); + +describe('transformToBeNullish', () => { + const testCases = [ + { + description: 'should transform toBeNullish', + input: `expect(element).toBeNullish();`, + expected: `expect(element === null || element === undefined).toBe(true);`, + }, + { + description: 'should transform not.toBeNullish', + input: `expect(element).not.toBeNullish();`, + expected: `expect(element === null || element === undefined).toBe(false);`, + }, + ]; + + 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 2872a3f7503e..66fa56ff4a9a 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -52,6 +52,20 @@ export function transformTimerMocks( case 'mockDate': newMethodName = 'setSystemTime'; break; + case 'autoTick': { + const category = 'clockAutoTick'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category); + + return node; + } + case 'withMock': { + const category = 'clockWithMock'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category); + + return node; + } } if (newMethodName) { @@ -85,15 +99,21 @@ export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorC node.expression.expression.text === 'fail' ) { reporter.reportTransformation(sourceFile, node, 'Transformed `fail()` to `throw new Error()`.'); - const reason = node.expression.arguments[0]; - const replacement = ts.factory.createThrowStatement( - ts.factory.createNewExpression( + const arg = node.expression.arguments[0]; + let throwExpression: ts.Expression; + + if (arg && ts.isNewExpression(arg)) { + throwExpression = arg; + } else { + throwExpression = ts.factory.createNewExpression( ts.factory.createIdentifier('Error'), undefined, - reason ? [reason] : [], - ), - ); + arg ? [arg] : [], + ); + } + + const replacement = ts.factory.createThrowStatement(throwExpression); return ts.setOriginalNode(ts.setTextRange(replacement, node), node); } @@ -101,10 +121,9 @@ export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorC return node; } -export function transformDefaultTimeoutInterval( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformJasmineMembers(node: ts.Node, refactorCtx: RefactorContext): ts.Node { + const { sourceFile, reporter } = refactorCtx; + if ( ts.isExpressionStatement(node) && ts.isBinaryExpression(node.expression) && @@ -114,38 +133,92 @@ export function transformDefaultTimeoutInterval( if ( ts.isPropertyAccessExpression(assignment.left) && ts.isIdentifier(assignment.left.expression) && - assignment.left.expression.text === 'jasmine' && - assignment.left.name.text === 'DEFAULT_TIMEOUT_INTERVAL' + assignment.left.expression.text === 'jasmine' ) { - addVitestValueImport(pendingVitestValueImports, 'vi'); - 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.updateExpressionStatement(node, setConfigCall); + const memberName = assignment.left.name.text; + + switch (memberName) { + case 'DEFAULT_TIMEOUT_INTERVAL': + return transformJasmineDefaultTimeoutInterval(node, assignment.right, refactorCtx); + case 'MAX_PRETTY_PRINT_ARRAY_LENGTH': + case 'MAX_PRETTY_PRINT_DEPTH': + case 'MAX_PRETTY_PRINT_CHARS': { + const replacement = ts.factory.createEmptyStatement(); + const originalText = node.getFullText().trim(); + + reporter.reportTransformation( + sourceFile, + node, + `Removed \`${memberName}\` member assignment.`, + ); + const category = 'unsupported-jasmine-member'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(replacement, category, { name: memberName }); + ts.addSyntheticLeadingComment( + replacement, + ts.SyntaxKind.SingleLineCommentTrivia, + ` ${originalText}`, + true, + ); + + return replacement; + } + } } } return node; } -export function transformGlobalFunctions( +function transformJasmineDefaultTimeoutInterval( + expression: ts.ExpressionStatement, + timeoutValue: ts.Expression, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, +): ts.Node { + addVitestValueImport(pendingVitestValueImports, 'vi'); + reporter.reportTransformation( + sourceFile, + expression, + 'Transformed `jasmine.DEFAULT_TIMEOUT_INTERVAL` to `vi.setConfig()`.', + ); + const setConfigCall = createViCallExpression('setConfig', [ + ts.factory.createObjectLiteralExpression( + [ts.factory.createPropertyAssignment('testTimeout', timeoutValue)], + false, + ), + ]); + + return ts.factory.updateExpressionStatement(expression, setConfigCall); +} + +const UNSUPPORTED_GLOBAL_FUNCTION_CATEGORIES = new Set([ + 'setSpecProperty', + 'setSuiteProperty', + 'throwUnless', + 'throwUnlessAsync', + 'getSpecProperty', +]); + +// A type guard to ensure that the methodName is one of the categories handled by this transformer. +function isUnsupportedGlobalFunction( + methodName: string, +): methodName is + | 'setSpecProperty' + | 'setSuiteProperty' + | 'throwUnless' + | 'throwUnlessAsync' + | 'getSpecProperty' { + return UNSUPPORTED_GLOBAL_FUNCTION_CATEGORIES.has(methodName as TodoCategory); +} + +export function transformUnsupportedGlobalFunctions( node: ts.Node, { sourceFile, reporter }: RefactorContext, ): ts.Node { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - (node.expression.text === 'setSpecProperty' || node.expression.text === 'setSuiteProperty') + isUnsupportedGlobalFunction(node.expression.text) ) { const functionName = node.expression.text; reporter.reportTransformation( @@ -153,9 +226,8 @@ export function transformGlobalFunctions( node, `Found unsupported global function \`${functionName}\`.`, ); - const category = 'unsupported-global-function'; - reporter.recordTodo(category, sourceFile, node); - addTodoComment(node, category, { name: functionName }); + reporter.recordTodo(functionName, sourceFile, node); + addTodoComment(node, functionName); } return node; @@ -163,15 +235,25 @@ export function transformGlobalFunctions( const UNSUPPORTED_JASMINE_CALLS_CATEGORIES = new Set([ 'addMatchers', + 'addAsyncMatchers', 'addCustomEqualityTester', + 'addCustomObjectFormatter', 'mapContaining', 'setContaining', + 'addSpyStrategy', ]); // A type guard to ensure that the methodName is one of the categories handled by this transformer. function isUnsupportedJasmineCall( methodName: string, -): methodName is 'addMatchers' | 'addCustomEqualityTester' | 'mapContaining' | 'setContaining' { +): methodName is + | 'addMatchers' + | 'addAsyncMatchers' + | 'addCustomEqualityTester' + | 'addCustomObjectFormatter' + | 'mapContaining' + | 'setContaining' + | 'addSpyStrategy' { return UNSUPPORTED_JASMINE_CALLS_CATEGORIES.has(methodName as TodoCategory); } @@ -200,6 +282,7 @@ const HANDLED_JASMINE_PROPERTIES = new Set([ 'createSpy', 'createSpyObj', 'spyOnAllFunctions', + 'addSpyStrategy', // Clock 'clock', // Matchers @@ -217,8 +300,13 @@ const HANDLED_JASMINE_PROPERTIES = new Set([ 'setContaining', // Other 'DEFAULT_TIMEOUT_INTERVAL', + 'MAX_PRETTY_PRINT_ARRAY_LENGTH', + 'MAX_PRETTY_PRINT_DEPTH', + 'MAX_PRETTY_PRINT_CHARS', 'addMatchers', + 'addAsyncMatchers', 'addCustomEqualityTester', + 'addCustomObjectFormatter', ]); export function transformUnknownJasmineProperties( 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 index c4e76f51c9fb..a5b29f2d2b6a 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc_spec.ts @@ -35,6 +35,18 @@ describe('Jasmine to Vitest Transformer - transformTimerMocks', () => { input: `jasmine.clock().mockDate();`, expected: `vi.setSystemTime(new Date());`, }, + { + description: 'should add a TODO for jasmine.clock().autoTick()', + input: 'jasmine.clock().autoTick();', + expected: `// TODO: vitest-migration: Vitest does not have a direct equivalent for jasmine.clock().autoTick(). Please migrate this manually. See: https://vitest.dev/api/vi.html#fake-timers +jasmine.clock().autoTick();`, + }, + { + description: 'should add a TODO for jasmine.clock().withMock()', + input: 'jasmine.clock().withMock(noop);', + expected: `// TODO: vitest-migration: Vitest does not have a direct equivalent for jasmine.clock().withMock(). Please migrate this manually via vi.useFakeTimers() and vi.useRealTimers(). See: https://vitest.dev/api/vi.html#vi-usefaketimers +jasmine.clock().withMock(noop);`, + }, ]; testCases.forEach(({ description, input, expected }) => { @@ -56,6 +68,11 @@ describe('transformFail', () => { input: `fail();`, expected: `throw new Error();`, }, + { + description: 'should transform fail() with an Error object', + input: `fail(new TypeError('Invalid input'));`, + expected: `throw new TypeError('Invalid input');`, + }, ]; testCases.forEach(({ description, input, expected }) => { @@ -65,13 +82,31 @@ describe('transformFail', () => { }); }); -describe('transformDefaultTimeoutInterval', () => { +describe('transformJasmineMembers', () => { const testCases = [ { description: 'should transform jasmine.DEFAULT_TIMEOUT_INTERVAL', input: `jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;`, expected: `vi.setConfig({ testTimeout: 10000 });`, }, + { + description: 'should remove jasmine.MAX_PRETTY_PRINT_ARRAY_LENGTH', + input: `jasmine.MAX_PRETTY_PRINT_ARRAY_LENGTH = 10;`, + expected: `// TODO: vitest-migration: jasmine.MAX_PRETTY_PRINT_ARRAY_LENGTH is not supported. +// jasmine.MAX_PRETTY_PRINT_ARRAY_LENGTH = 10;`, + }, + { + description: 'should remove jasmine.MAX_PRETTY_PRINT_DEPTH', + input: `jasmine.MAX_PRETTY_PRINT_DEPTH = 10;`, + expected: `// TODO: vitest-migration: jasmine.MAX_PRETTY_PRINT_DEPTH is not supported. +// jasmine.MAX_PRETTY_PRINT_DEPTH = 10;`, + }, + { + description: 'should remove jasmine.MAX_PRETTY_PRINT_CHARS', + input: `jasmine.MAX_PRETTY_PRINT_CHARS = 100;`, + expected: `// TODO: vitest-migration: jasmine.MAX_PRETTY_PRINT_CHARS is not supported. +// jasmine.MAX_PRETTY_PRINT_CHARS = 100;`, + }, ]; testCases.forEach(({ description, input, expected }) => { @@ -81,7 +116,7 @@ describe('transformDefaultTimeoutInterval', () => { }); }); -describe('transformAddMatchers', () => { +describe('transformUnsupportedJasmineCalls', () => { const testCases = [ { description: 'should add a TODO for jasmine.addMatchers', @@ -113,17 +148,24 @@ describe('transformAddMatchers', () => { }); `, }, - ]; - - testCases.forEach(({ description, input, expected }) => { - it(description, async () => { - await expectTransformation(input, expected); - }); - }); -}); - -describe('transformAddCustomEqualityTester', () => { - const testCases = [ + { + description: 'should add a TODO for jasmine.addAsyncMatchers', + input: ` + jasmine.addAsyncMatchers({ + toEventuallyEqual: () => ({ + compare: async (actual, expected) => ({ pass: actual === expected }), + }), + }); + `, + expected: ` + // TODO: vitest-migration: jasmine.addAsyncMatchers is not supported. Please manually migrate to expect.extend(). See: https://vitest.dev/api/expect.html#expect-extend + jasmine.addAsyncMatchers({ + toEventuallyEqual: () => ({ + compare: async (actual, expected) => ({ pass: actual === expected }), + }), + }); + `, + }, { description: 'should add a TODO for jasmine.addCustomEqualityTester', input: ` @@ -137,6 +179,39 @@ describe('transformAddCustomEqualityTester', () => { }); `, }, + { + description: 'should add a TODO for jasmine.addCustomObjectFormatter', + input: ` + jasmine.addCustomObjectFormatter((val) => { + if (val instanceof MyClass) return 'MyClass(' + val.id + '})'; + }); + `, + expected: `// TODO: vitest-migration: jasmine.addCustomObjectFormatter is not supported. May be possible to migrate to expect.addSnapshotSerializer(). See: https://vitest.dev/api/expect.html#expect-addsnapshotserializer + jasmine.addCustomObjectFormatter((val) => { + if (val instanceof MyClass) return 'MyClass(' + val.id + '})'; + }); + `, + }, + { + 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()));`, + }, + { + description: 'should add a TODO for jasmine.addSpyStrategy', + input: `jasmine.addSpyStrategy('returnZero', () => () => 0);`, + expected: `// TODO: vitest-migration: jasmine.addSpyStrategy is not supported. Please manually migrate to spy.mockImplementation(). See: https://vitest.dev/api/mock.html#mockimplementation +jasmine.addSpyStrategy('returnZero', () => () => 0);`, + }, ]; testCases.forEach(({ description, input, expected }) => { @@ -168,7 +243,7 @@ const env = jasmine.getEnv();`, }); }); -describe('transformGlobalFunctions', () => { +describe('transformUnsupportedGlobalFunctions', () => { const testCases = [ { description: 'should add a TODO for setSpecProperty', @@ -184,30 +259,25 @@ setSpecProperty('myKey', 'myValue');`, 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()));`, + description: 'should add a TODO for throwUnless', + input: `throwUnless(x).toBe(y);`, // 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()));`, + expected: `// TODO: vitest-migration: Unsupported global function \`throwUnless\` found. Please migrate manually to a direct assertion. +throwUnless(x).toBe(y);`, }, { - description: 'should add a TODO for jasmine.setContaining', - input: `expect(mySet).toEqual(jasmine.setContaining(new Set()));`, + description: 'should add a TODO for throwUnlessAsync', + input: `await throwUnlessAsync(promise).toBeResolved();`, // 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()));`, + expected: `// TODO: vitest-migration: Unsupported global function \`throwUnlessAsync\` found. Please migrate manually to a direct assertion. +await throwUnlessAsync(promise).toBeResolved();`, + }, + { + description: 'should add a TODO for getSpecProperty', + input: `const val = getSpecProperty('myKey');`, + expected: `// TODO: vitest-migration: Unsupported global function \`getSpecProperty\` found. Please migrate manually. +const val = getSpecProperty('myKey');`, }, ]; 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 1139aedc8aed..f6066adfb81b 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -18,6 +18,7 @@ import { addVitestValueImport, createPropertyAccess, createViCallExpression, + getPromiseResolveRejectMethod, } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; @@ -58,11 +59,25 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ) { const spyCall = pae.expression.expression; let newMethodName: string | undefined; + let args = node.arguments; + if (ts.isIdentifier(pae.name)) { const strategyName = pae.name.text; switch (strategyName) { case 'returnValue': - newMethodName = 'mockReturnValue'; + { + const result = getPromiseResolveRejectMethod(args[0]); + if (result) { + const methodMapping = { + 'resolve': 'mockResolvedValue', + 'reject': 'mockRejectedValue', + }; + newMethodName = methodMapping[result.methodName]; + args = result.arguments; + } else { + newMethodName = 'mockReturnValue'; + } + } break; case 'resolveTo': newMethodName = 'mockResolvedValue'; @@ -151,6 +166,16 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. return ts.factory.createCallExpression(newExpression, undefined, [arrowFunction]); } + case 'identity': { + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.and.identity()` to `.getMockName()`.', + ); + const newExpression = createPropertyAccess(spyCall, 'getMockName'); + + return ts.factory.createCallExpression(newExpression, undefined, undefined); + } default: { const category = 'unsupported-spy-strategy'; reporter.recordTodo(category, sourceFile, node); @@ -172,46 +197,60 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ts.factory.createIdentifier(newMethodName), ); - return ts.factory.updateCallExpression( - node, - newExpression, - node.typeArguments, - node.arguments, - ); + return ts.factory.updateCallExpression(node, newExpression, node.typeArguments, args); } } } } - const jasmineMethodName = getJasmineMethodName(node); - switch (jasmineMethodName) { - case 'createSpy': - addVitestValueImport(pendingVitestValueImports, 'vi'); - reporter.reportTransformation( - sourceFile, - node, - 'Transformed `jasmine.createSpy()` to `vi.fn()`.', - ); - - // jasmine.createSpy(name, originalFn) -> vi.fn(originalFn) - return createViCallExpression('fn', node.arguments.length > 1 ? [node.arguments[1]] : []); - case 'spyOnAllFunctions': { - reporter.reportTransformation( - sourceFile, - node, - 'Found unsupported `jasmine.spyOnAllFunctions()`.', - ); - const category = 'spyOnAllFunctions'; - reporter.recordTodo(category, sourceFile, node); - addTodoComment(node, category); + if (getJasmineMethodName(node) === 'spyOnAllFunctions') { + reporter.reportTransformation( + sourceFile, + node, + 'Found unsupported `jasmine.spyOnAllFunctions()`.', + ); + const category = 'spyOnAllFunctions'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category); - return node; - } + return node; } return node; } +export function transformCreateSpy( + node: ts.Node, + { reporter, sourceFile, pendingVitestValueImports }: RefactorContext, +): ts.Node { + if (!isJasmineCallExpression(node, 'createSpy')) { + return node; + } + + addVitestValueImport(pendingVitestValueImports, 'vi'); + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `jasmine.createSpy()` to `vi.fn()`.', + ); + + const spyName = node.arguments[0]; + const viFnCallExpression = createViCallExpression( + 'fn', + node.arguments.length > 1 ? [node.arguments[1]] : [], + ); + + // jasmine.createSpy() -> vi.fn() + // jasmine.createSpy(name, originalFn) -> vi.fn(originalFn).mockName(name) + return !spyName + ? viFnCallExpression + : ts.factory.createCallExpression( + createPropertyAccess(viFnCallExpression, 'mockName'), + undefined, + [node.arguments[0]], + ); +} + export function transformCreateSpyObj( node: ts.Node, { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, @@ -428,12 +467,56 @@ function transformMostRecentArgs( return createPropertyAccess(mockProperty, 'lastCall'); } +function transformThisFor( + node: ts.Node, + { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, +): ts.Node { + // Check 1: Is the node is a call expression? + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { + return node; + } + + // Check 2: Is it a call to `.thisFor`? + const thisForPae = node.expression; + if ( + !ts.isIdentifier(thisForPae.name) || + thisForPae.name.text !== 'thisFor' || + !ts.isPropertyAccessExpression(thisForPae.expression) + ) { + return node; + } + + // Check 3: Can we get the spy identifier from `spy.calls`? + const spyIdentifier = getSpyIdentifierFromCalls(thisForPae.expression); + if (!spyIdentifier) { + return node; + } + + // If all checks pass, perform the transformation. + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `spy.calls.thisFor(index)` to `vi.mocked(spy).mock.contexts[index]`.', + ); + const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports); + + return ts.factory.createElementAccessExpression( + createPropertyAccess(mockProperty, 'contexts'), + node.arguments[0] ?? 0, + ); +} + export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorContext): ts.Node { const mostRecentArgsTransformed = transformMostRecentArgs(node, refactorCtx); if (mostRecentArgsTransformed !== node) { return mostRecentArgsTransformed; } + const thisForTransformed = transformThisFor(node, refactorCtx); + if (thisForTransformed !== node) { + return thisForTransformed; + } + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { return node; } @@ -479,6 +562,13 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC message = 'Transformed `spy.calls.argsFor()` to `mock.calls[i]`.'; newExpression = ts.factory.createElementAccessExpression(callsProperty, node.arguments[0]); break; + case 'saveArgumentsByValue': + { + const category = 'saveArgumentsByValue'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category); + } + break; case 'mostRecent': if ( !ts.isPropertyAccessExpression(node.parent) || diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy_spec.ts index 44113c3938c2..85a0068240c7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy_spec.ts @@ -20,6 +20,18 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { input: `spyOn(service, 'myMethod').and.returnValue(42);`, expected: `vi.spyOn(service, 'myMethod').mockReturnValue(42);`, }, + { + description: + 'should transform .and.returnValue(Promise.resolve(...)) to .mockResolvedValue(...)', + input: `spyOn(service, 'myMethod').and.returnValue(Promise.resolve(42));`, + expected: `vi.spyOn(service, 'myMethod').mockResolvedValue(42);`, + }, + { + description: + 'should transform .and.returnValue(Promise.reject(...)) to .mockRejectedValue(...)', + input: `spyOn(service, 'myMethod').and.returnValue(Promise.reject(42));`, + expected: `vi.spyOn(service, 'myMethod').mockRejectedValue(42);`, + }, { description: 'should transform .and.returnValues() to chained .mockReturnValueOnce() calls', input: `spyOn(service, 'myMethod').and.returnValues('a', 'b', 'c');`, @@ -36,14 +48,14 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { expected: `vi.spyOn(service, 'myMethod');`, }, { - description: 'should transform jasmine.createSpy("name") to vi.fn()', + description: 'should transform jasmine.createSpy("name") to vi.fn().mockName("name")', input: `const mySpy = jasmine.createSpy('mySpy');`, - expected: `const mySpy = vi.fn();`, + expected: `const mySpy = vi.fn().mockName('mySpy');`, }, { - description: 'should transform jasmine.createSpy("name", fn) to vi.fn(fn)', + description: 'should transform jasmine.createSpy("name", fn) to vi.fn(fn).mockName("name")', input: `const mySpy = jasmine.createSpy('mySpy', () => 'foo');`, - expected: `const mySpy = vi.fn(() => 'foo');`, + expected: `const mySpy = vi.fn(() => 'foo').mockName('mySpy');`, }, { description: 'should transform spyOnProperty(object, "prop") to vi.spyOn(object, "prop")', @@ -65,7 +77,7 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { { description: 'should handle chained calls on jasmine.createSpy()', input: `const mySpy = jasmine.createSpy('mySpy').and.returnValue(true);`, - expected: `const mySpy = vi.fn().mockReturnValue(true);`, + expected: `const mySpy = vi.fn().mockName('mySpy').mockReturnValue(true);`, }, { description: 'should handle .and.returnValues() with no arguments', @@ -94,6 +106,11 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { input: `spyOn(service, 'myMethod').and.rejectWith('some error');`, expected: `vi.spyOn(service, 'myMethod').mockRejectedValue('some error');`, }, + { + description: 'should transform .and.identity() to .getMockName()', + input: `spyOn(service, 'myMethod').and.identity();`, + expected: `vi.spyOn(service, 'myMethod').getMockName();`, + }, { description: 'should add a TODO for an unsupported spy strategy', input: `spyOn(service, 'myMethod').and.unknownStrategy();`, @@ -258,6 +275,11 @@ describe('transformSpyCallInspection', () => { input: `const recentArgs = mySpy.calls.mostRecent().args;`, expected: `const recentArgs = vi.mocked(mySpy).mock.lastCall;`, }, + { + description: 'should transform spy.calls.thisFor(index)', + input: `const context = mySpy.calls.thisFor(1337);`, + expected: `const context = vi.mocked(mySpy).mock.contexts[1337];`, + }, { description: 'should transform spy.calls.first()', input: `const firstCall = mySpy.calls.first();`, @@ -269,6 +291,14 @@ describe('transformSpyCallInspection', () => { expected: `// TODO: vitest-migration: Direct usage of mostRecent() is not supported. Please refactor to access .args directly or use vi.mocked(spy).mock.lastCall. See: https://vitest.dev/api/mocked.html#mock-lastcall const mostRecent = mySpy.calls.mostRecent();`, }, + { + description: 'should add a TODO for spy.calls.saveArgumentsByValue()', + input: `const saveArgs = mySpy.calls.saveArgumentsByValue();`, + expected: + '// TODO: vitest-migration: Vitest does not have a direct equivalent for spy.calls.saveArgumentsByValue().' + + ' Please migrate this manually by cloning and storing the arguments in a local variable.' + + '\nconst saveArgs = mySpy.calls.saveArgumentsByValue();', + }, ]; testCases.forEach(({ description, 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 f6f363df1643..f63b128d5edc 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -92,3 +92,32 @@ export function createPropertyAccess( name, ); } + +export function getPromiseResolveRejectMethod(node: ts.Node): { + methodName: 'resolve' | 'reject'; + arguments: ts.NodeArray; +} | null { + if (!ts.isCallExpression(node)) { + return null; + } + + const expr = node.expression; + if ( + !ts.isPropertyAccessExpression(expr) || + !ts.isIdentifier(expr.expression) || + expr.expression.escapedText !== 'Promise' + ) { + return null; + } + + const methodName = expr.name.escapedText as string; + const isResolveReject = methodName === 'resolve' || methodName === 'reject'; + if (!isResolveReject) { + return null; + } + + return { + methodName, + arguments: node.arguments, + }; +} 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 8a9d888a0298..2a3f155a9393 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -64,20 +64,49 @@ export const TODO_NOTES = { message: 'expect().nothing() has been removed because it is redundant in Vitest. Tests without assertions pass by default.', }, - 'unsupported-global-function': { - message: (context: { name: string }): string => - `Unsupported global function \`${context.name}\` found. This function is used for custom reporters in Jasmine ` + + 'unsupported-jasmine-member': { + message: (context: { name: string }): string => `jasmine.${context.name} is not supported.`, + }, + 'setSpecProperty': { + message: + 'Unsupported global function `setSpecProperty` found. This function is used for custom reporters in Jasmine ' + 'and has no direct equivalent in Vitest.', }, + 'setSuiteProperty': { + message: + 'Unsupported global function `setSuiteProperty` found. This function is used for custom reporters in Jasmine ' + + 'and has no direct equivalent in Vitest.', + }, + 'throwUnless': { + message: + 'Unsupported global function `throwUnless` found. Please migrate manually to a direct assertion.', + }, + 'throwUnlessAsync': { + message: + 'Unsupported global function `throwUnlessAsync` found. Please migrate manually to a direct assertion.', + }, + 'getSpecProperty': { + message: 'Unsupported global function `getSpecProperty` found. Please migrate manually.', + }, 'addMatchers': { message: 'jasmine.addMatchers is not supported. Please manually migrate to expect.extend().', url: 'https://vitest.dev/api/expect.html#expect-extend', }, + 'addAsyncMatchers': { + message: + 'jasmine.addAsyncMatchers is not supported. Please manually migrate to expect.extend().', + url: 'https://vitest.dev/api/expect.html#expect-extend', + }, 'addCustomEqualityTester': { message: 'jasmine.addCustomEqualityTester is not supported. Please manually migrate to expect.addEqualityTesters().', url: 'https://vitest.dev/api/expect.html#expect-addequalitytesters', }, + 'addCustomObjectFormatter': { + message: + 'jasmine.addCustomObjectFormatter is not supported. May be possible to migrate to expect.addSnapshotSerializer().', + url: 'https://vitest.dev/api/expect.html#expect-addsnapshotserializer', + }, 'mapContaining': { message: 'jasmine.mapContaining is not supported. Vitest does not have a built-in matcher for Maps.' + @@ -88,6 +117,11 @@ export const TODO_NOTES = { 'jasmine.setContaining is not supported. Vitest does not have a built-in matcher for Sets.' + ' Please manually assert the contents of the Set.', }, + 'addSpyStrategy': { + message: + 'jasmine.addSpyStrategy is not supported. Please manually migrate to spy.mockImplementation().', + url: 'https://vitest.dev/api/mock.html#mockimplementation', + }, 'unknown-jasmine-property': { message: (context: { name: string }): string => `Unsupported jasmine property "${context.name}" found. Please migrate this manually.`, @@ -124,6 +158,23 @@ export const TODO_NOTES = { ' Please refactor to access .args directly or use vi.mocked(spy).mock.lastCall.', url: 'https://vitest.dev/api/mocked.html#mock-lastcall', }, + 'saveArgumentsByValue': { + message: + 'Vitest does not have a direct equivalent for spy.calls.saveArgumentsByValue().' + + ' Please migrate this manually by cloning and storing the arguments in a local variable.', + }, + 'clockAutoTick': { + message: + 'Vitest does not have a direct equivalent for jasmine.clock().autoTick(). Please migrate this manually.', + url: 'https://vitest.dev/api/vi.html#fake-timers', + }, + 'clockWithMock': { + message: + 'Vitest does not have a direct equivalent for jasmine.clock().withMock().' + + ' Please migrate this manually via vi.useFakeTimers() and vi.useRealTimers().', + url: 'https://vitest.dev/api/vi.html#vi-usefaketimers', + }, + 'unhandled-done-usage': { message: "The 'done' callback was used in an unhandled way. Please migrate manually.", },