diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/index_spec.ts index fcb804886286..a1abb7562ee8 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index_spec.ts @@ -59,7 +59,7 @@ describe('Jasmine to Vitest Schematic', () => { ); const newContent = tree.readContent(specFilePath); - expect(newContent).toContain(`vi.spyOn(service, 'myMethod');`); + expect(newContent).toContain(`vi.spyOn(service, 'myMethod').mockReturnValue(undefined);`); }); it('should only transform files matching the fileSuffix option', async () => { @@ -94,7 +94,7 @@ describe('Jasmine to Vitest Schematic', () => { expect(unchangedContent).not.toContain(`vi.spyOn(window, 'alert');`); const changedContent = tree.readContent(testFilePath); - expect(changedContent).toContain(`vi.spyOn(window, 'confirm');`); + expect(changedContent).toContain(`vi.spyOn(window, 'confirm').mockReturnValue(undefined);`); }); it('should print verbose logs when the verbose option is true', async () => { @@ -144,7 +144,7 @@ describe('Jasmine to Vitest Schematic', () => { ); const changedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts'); - expect(changedContent).toContain(`vi.spyOn(window, 'confirm');`); + expect(changedContent).toContain(`vi.spyOn(window, 'confirm').mockReturnValue(undefined);`); const unchangedContent = tree.readContent('projects/bar/src/app/app.spec.ts'); expect(unchangedContent).toContain(`spyOn(window, 'alert');`); @@ -158,7 +158,7 @@ describe('Jasmine to Vitest Schematic', () => { ); const changedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts'); - expect(changedContent).toContain(`vi.spyOn(window, 'confirm');`); + expect(changedContent).toContain(`vi.spyOn(window, 'confirm').mockReturnValue(undefined);`); const unchangedContent = tree.readContent('projects/bar/src/app/app.spec.ts'); expect(unchangedContent).toContain(`spyOn(window, 'alert');`); @@ -177,10 +177,12 @@ describe('Jasmine to Vitest Schematic', () => { ); const changedAppContent = tree.readContent('projects/bar/src/app/app.spec.ts'); - expect(changedAppContent).toContain(`vi.spyOn(window, 'alert');`); + expect(changedAppContent).toContain(`vi.spyOn(window, 'alert').mockReturnValue(undefined);`); const changedNestedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts'); - expect(changedNestedContent).toContain(`vi.spyOn(window, 'confirm');`); + expect(changedNestedContent).toContain( + `vi.spyOn(window, 'confirm').mockReturnValue(undefined);`, + ); const unchangedContent = tree.readContent('projects/bar/src/other/other.spec.ts'); expect(unchangedContent).toContain(`spyOn(window, 'close');`); @@ -194,10 +196,12 @@ describe('Jasmine to Vitest Schematic', () => { ); const changedAppContent = tree.readContent('projects/bar/src/app/app.spec.ts'); - expect(changedAppContent).toContain(`vi.spyOn(window, 'alert');`); + expect(changedAppContent).toContain(`vi.spyOn(window, 'alert').mockReturnValue(undefined);`); const changedNestedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts'); - expect(changedNestedContent).toContain(`vi.spyOn(window, 'confirm');`); + expect(changedNestedContent).toContain( + `vi.spyOn(window, 'confirm').mockReturnValue(undefined);`, + ); }); it('should throw if the include path does not exist', async () => { 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 b84aeba57411..5b30e9f24f4b 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 @@ -109,7 +109,7 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { }); it('should handle user click', () => { - vi.spyOn(window, 'alert'); + vi.spyOn(window, 'alert').mockReturnValue(undefined); const button = fixture.nativeElement.querySelector('button'); button.click(); fixture.detectChanges(); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts index 82b76ee31782..f4b10d485920 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer_add-imports_spec.ts @@ -13,7 +13,7 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { const input = `spyOn(foo, 'bar');`; const expected = ` import { vi } from 'vitest'; - vi.spyOn(foo, 'bar'); + vi.spyOn(foo, 'bar').mockReturnValue(undefined); `; await expectTransformation(input, expected, true); }); @@ -27,7 +27,7 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { import { type Mock, vi } from 'vitest'; let mySpy: Mock; - vi.spyOn(foo, 'bar'); + vi.spyOn(foo, 'bar').mockReturnValue(undefined); `; await expectTransformation(input, expected, true); }); @@ -41,7 +41,7 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { import type { Mock } from 'vitest'; let mySpy: Mock; - vi.spyOn(foo, 'bar'); + vi.spyOn(foo, 'bar').mockReturnValue(undefined); `; await expectTransformation(input, expected, false); }); 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 c840c374976d..740628d41fcd 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -24,185 +24,255 @@ import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; import { createViCallExpression } from '../utils/refactor-helpers'; -export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { - const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; - if (!ts.isCallExpression(node)) { - return node; +function isChainedWithAnd(node: ts.Node): boolean { + let parent = node.parent; + while (parent) { + if (ts.isPropertyAccessExpression(parent)) { + if (ts.isIdentifier(parent.name) && parent.name.text === 'and') { + return true; + } + } else if (ts.isElementAccessExpression(parent)) { + if ( + ts.isStringLiteralLike(parent.argumentExpression) && + parent.argumentExpression.text === 'and' + ) { + return true; + } + } else if ( + ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isSatisfiesExpression(parent) + ) { + parent = parent.parent; + continue; + } + break; } + return false; +} + +function transformPrimarySpy(node: ts.CallExpression, refactorCtx: RefactorContext): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; if ( ts.isIdentifier(node.expression) && (node.expression.text === 'spyOn' || node.expression.text === 'spyOnProperty') ) { addVitestValueImport(pendingVitestValueImports, 'vi'); - reporter.reportTransformation( - sourceFile, - node, - `Transformed \`${node.expression.text}\` to \`vi.spyOn\`.`, - ); - return ts.factory.updateCallExpression( + const viSpyOnCall = ts.factory.updateCallExpression( node, createPropertyAccess('vi', 'spyOn'), node.typeArguments, node.arguments, ); + + if (isChainedWithAnd(node)) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed \`${node.expression.text}\` to \`vi.spyOn\`.`, + ); + + return viSpyOnCall; + } + + reporter.reportTransformation( + sourceFile, + node, + `Transformed \`${node.expression.text}\` to \`vi.spyOn\`, ` + + `appending \`.mockReturnValue(undefined)\` to preserve stub-by-default semantics.`, + ); + + return ts.factory.createCallExpression( + createPropertyAccess(viSpyOnCall, 'mockReturnValue'), + undefined, + [ts.factory.createIdentifier('undefined')], + ); } - if (ts.isPropertyAccessExpression(node.expression)) { - const pae = node.expression; + return node; +} - if ( - ts.isPropertyAccessExpression(pae.expression) && - ts.isIdentifier(pae.expression.name) && - pae.expression.name.text === 'and' - ) { - 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': - { - 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'; - break; - case 'rejectWith': - newMethodName = 'mockRejectedValue'; - break; - case 'returnValues': { - reporter.reportTransformation( - sourceFile, - node, - 'Transformed `.and.returnValues()` to chained `.mockReturnValueOnce()` calls.', - ); - const returnValues = node.arguments; - if (returnValues.length === 0) { - // No values, so it's a no-op. Just transform the spyOn call. - return transformSpies(spyCall, refactorCtx); - } - // spy.and.returnValues(a, b) -> spy.mockReturnValueOnce(a).mockReturnValueOnce(b) - let chainedCall: ts.Expression = spyCall; - for (const value of returnValues) { - const mockCall = ts.factory.createCallExpression( - createPropertyAccess(chainedCall, 'mockReturnValueOnce'), - undefined, - [value], - ); - chainedCall = mockCall; - } +function transformSpyStrategy(node: ts.CallExpression, refactorCtx: RefactorContext): ts.Node { + const { sourceFile, reporter } = refactorCtx; + if (!ts.isPropertyAccessExpression(node.expression)) { + return node; + } - return chainedCall; - } - case 'callFake': - newMethodName = 'mockImplementation'; - break; - case 'callThrough': - reporter.reportTransformation( - sourceFile, - node, - 'Removed redundant `.and.callThrough()` call.', - ); + const pae = node.expression; + let spyCall: ts.Expression | undefined; - return transformSpies(spyCall, refactorCtx); // .and.callThrough() is redundant, just transform spyOn. - case 'stub': { - reporter.reportTransformation( - sourceFile, - node, - 'Transformed `.and.stub()` to `.mockImplementation()`.', - ); - const newExpression = createPropertyAccess(spyCall, 'mockImplementation'); - const arrowFn = ts.factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock([], /* multiline */ true), - ); + if ( + ts.isPropertyAccessExpression(pae.expression) && + ts.isIdentifier(pae.expression.name) && + pae.expression.name.text === 'and' + ) { + spyCall = pae.expression.expression; + } else if ( + ts.isElementAccessExpression(pae.expression) && + ts.isStringLiteralLike(pae.expression.argumentExpression) && + pae.expression.argumentExpression.text === 'and' + ) { + spyCall = pae.expression.expression; + } - return ts.factory.createCallExpression(newExpression, undefined, [arrowFn]); + if (spyCall) { + let newMethodName: string | undefined; + let args = node.arguments; + + if (ts.isIdentifier(pae.name)) { + const strategyName = pae.name.text; + switch (strategyName) { + case 'returnValue': + { + const firstArg = args[0]; + const result = firstArg ? getPromiseResolveRejectMethod(firstArg) : null; + if (result) { + const methodMapping = { + 'resolve': 'mockResolvedValue', + 'reject': 'mockRejectedValue', + }; + newMethodName = methodMapping[result.methodName]; + args = result.arguments; + } else { + newMethodName = 'mockReturnValue'; + } } - case 'throwError': { - reporter.reportTransformation( - sourceFile, - node, - 'Transformed `.and.throwError()` to `.mockImplementation()`.', - ); - const errorArg = node.arguments[0]; - const throwStatement = ts.factory.createThrowStatement( - ts.isNewExpression(errorArg) - ? errorArg - : ts.factory.createNewExpression( - ts.factory.createIdentifier('Error'), - undefined, - node.arguments, - ), - ); - const arrowFunction = ts.factory.createArrowFunction( - undefined, - undefined, - [], + break; + case 'resolveTo': + newMethodName = 'mockResolvedValue'; + break; + case 'rejectWith': + newMethodName = 'mockRejectedValue'; + break; + case 'returnValues': { + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.and.returnValues()` to chained `.mockReturnValueOnce()` calls.', + ); + const returnValues = node.arguments; + if (returnValues.length === 0) { + // No values, so it's a no-op. Just transform the spyOn call. + return transformSpies(spyCall, refactorCtx); + } + // spy.and.returnValues(a, b) -> spy.mockReturnValueOnce(a).mockReturnValueOnce(b) + let chainedCall: ts.Expression = spyCall; + for (const value of returnValues) { + const mockCall = ts.factory.createCallExpression( + createPropertyAccess(chainedCall, 'mockReturnValueOnce'), undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock([throwStatement], true), + [value], ); - const newExpression = createPropertyAccess(spyCall, 'mockImplementation'); - - return ts.factory.createCallExpression(newExpression, undefined, [arrowFunction]); + chainedCall = mockCall; } - 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); - addTodoComment(node, category, { name: strategyName }); - break; - } + return chainedCall; } + case 'callFake': + newMethodName = 'mockImplementation'; + break; + case 'callThrough': + reporter.reportTransformation( + sourceFile, + node, + 'Removed redundant `.and.callThrough()` call.', + ); - if (newMethodName) { + return transformSpies(spyCall, refactorCtx); // .and.callThrough() is redundant, just transform spyOn. + case 'stub': { reporter.reportTransformation( sourceFile, node, - `Transformed spy strategy \`.and.${strategyName}()\` to \`.${newMethodName}()\`.`, + 'Transformed `.and.stub()` to `.mockImplementation()`.', + ); + const newExpression = createPropertyAccess(spyCall, 'mockImplementation'); + const arrowFn = ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([], /* multiline */ true), + ); + + return ts.factory.createCallExpression(newExpression, undefined, [arrowFn]); + } + case 'throwError': { + reporter.reportTransformation( + sourceFile, + node, + 'Transformed `.and.throwError()` to `.mockImplementation()`.', + ); + const errorArg = node.arguments[0]; + const throwStatement = ts.factory.createThrowStatement( + errorArg && ts.isNewExpression(errorArg) + ? errorArg + : ts.factory.createNewExpression( + ts.factory.createIdentifier('Error'), + undefined, + errorArg ? [errorArg] : [], + ), + ); + const arrowFunction = ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([throwStatement], true), ); + const newExpression = createPropertyAccess(spyCall, 'mockImplementation'); - const newExpression = ts.factory.updatePropertyAccessExpression( - pae, - spyCall, - ts.factory.createIdentifier(newMethodName), + 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.updateCallExpression(node, newExpression, node.typeArguments, args); + return ts.factory.createCallExpression(newExpression, undefined, undefined); } + default: { + const category = 'unsupported-spy-strategy'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category, { name: strategyName }); + break; + } + } + + if (newMethodName) { + reporter.reportTransformation( + sourceFile, + node, + `Transformed spy strategy \`.and.${strategyName}()\` to \`.${newMethodName}()\`.`, + ); + + const newExpression = ts.factory.updatePropertyAccessExpression( + pae, + spyCall, + ts.factory.createIdentifier(newMethodName), + ); + + return ts.factory.updateCallExpression(node, newExpression, node.typeArguments, args); } } } + return node; +} + +function transformSpyOnAllFunctions( + node: ts.CallExpression, + refactorCtx: RefactorContext, +): ts.Node { + const { sourceFile, reporter } = refactorCtx; if (getJasmineMethodName(node) === 'spyOnAllFunctions') { reporter.reportTransformation( sourceFile, @@ -219,6 +289,24 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. return node; } +export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { + if (!ts.isCallExpression(node)) { + return node; + } + + const primaryResult = transformPrimarySpy(node, refactorCtx); + if (primaryResult !== node) { + return primaryResult; + } + + const strategyResult = transformSpyStrategy(node, refactorCtx); + if (strategyResult !== node) { + return strategyResult; + } + + return transformSpyOnAllFunctions(node, refactorCtx); +} + export function transformCreateSpy(node: ts.Node, ctx: RefactorContext): ts.Node { const { reporter, sourceFile, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpy')) { 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 97881049c1d5..81ae0ff02bb8 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 @@ -11,9 +11,10 @@ import { expectTransformation } from '../test-helpers'; describe('Jasmine to Vitest Transformer - transformSpies', () => { const testCases = [ { - description: 'should transform spyOn(object, "method") to vi.spyOn(object, "method")', + description: + 'should transform spyOn(object, "method") to vi.spyOn(object, "method").mockReturnValue(undefined)', input: `spyOn(service, 'myMethod');`, - expected: `vi.spyOn(service, 'myMethod');`, + expected: `vi.spyOn(service, 'myMethod').mockReturnValue(undefined);`, }, { description: 'should transform .and.returnValue(...) to .mockReturnValue(...)', @@ -58,9 +59,10 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { expected: `const mySpy = vi.fn(() => 'foo').mockName('mySpy');`, }, { - description: 'should transform spyOnProperty(object, "prop") to vi.spyOn(object, "prop")', + description: + 'should transform spyOnProperty(object, "prop") to vi.spyOn(object, "prop").mockReturnValue(undefined)', input: `spyOnProperty(service, 'myProp');`, - expected: `vi.spyOn(service, 'myProp');`, + expected: `vi.spyOn(service, 'myProp').mockReturnValue(undefined);`, }, { description: 'should transform .and.stub() to .mockImplementation(() => {})', @@ -117,6 +119,36 @@ describe('Jasmine to Vitest Transformer - transformSpies', () => { expected: `// TODO: vitest-migration: Unsupported spy strategy ".and.unknownStrategy()" found. Please migrate this manually. See: https://vitest.dev/api/mocked.html#mock vi.spyOn(service, 'myMethod').and.unknownStrategy();`, }, + { + description: 'should correctly identify chained spies with element access (bracket notation)', + input: `spyOn(service, 'myMethod')['and'].returnValue(42);`, + expected: `vi.spyOn(service, 'myMethod').mockReturnValue(42);`, + }, + { + description: 'should correctly identify chained spies with non-null assertion', + input: `(spyOn(service, 'myMethod')!).and.returnValue(42);`, + expected: `(vi.spyOn(service, 'myMethod')!).mockReturnValue(42);`, + }, + { + description: 'should correctly identify chained spies with type assertion', + input: `(spyOn(service, 'myMethod')).and.returnValue(42);`, + expected: `(vi.spyOn(service, 'myMethod')).mockReturnValue(42);`, + }, + { + description: 'should correctly identify chained spies with satisfies expression', + input: `(spyOn(service, 'myMethod') satisfies any).and.returnValue(42);`, + expected: `(vi.spyOn(service, 'myMethod') satisfies any).mockReturnValue(42);`, + }, + { + description: 'should handle and.returnValue() without arguments defensively', + input: `spyOn(service, 'myMethod').and.returnValue();`, + expected: `vi.spyOn(service, 'myMethod').mockReturnValue();`, + }, + { + description: 'should handle and.throwError() without arguments defensively', + input: `spyOn(service, 'myMethod').and.throwError();`, + expected: `vi.spyOn(service, 'myMethod').mockImplementation(() => { throw new Error() });`, + }, ]; testCases.forEach(({ description, input, expected }) => {