From fbda00f116eab6b8a7fec9542cce4f2c1932bb5b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 28 May 2026 10:46:14 -0400 Subject: [PATCH 1/2] fix(@schematics/angular): preserve Jasmine stub-by-default semantics for bare spies The refactor-jasmine-vitest schematic previously migrated bare spyOn and spyOnProperty calls as a direct mechanical rename to vi.spyOn. Since the APIs feature opposing default behaviors (with jasmine.spyOn stubbing by default and vi.spyOn calling through), this caused migrated test suites to silently change behavior. This update structurally analyzes the AST during translation to detect chained strategies, appending .mockReturnValue(undefined) precisely for bare spies to retain original Jasmine semantics. Fixes #33253 --- .../refactor/jasmine-vitest/index_spec.ts | 20 +++-- .../test-file-transformer.integration_spec.ts | 2 +- .../test-file-transformer_add-imports_spec.ts | 6 +- .../transformers/jasmine-spy.ts | 81 ++++++++++++++++--- .../transformers/jasmine-spy_spec.ts | 40 ++++++++- 5 files changed, 123 insertions(+), 26 deletions(-) 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..10d7c66e09b3 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -24,6 +24,37 @@ import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; import { createViCallExpression } from '../utils/refactor-helpers'; +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; +} + +/* eslint-disable max-lines-per-function */ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; if (!ts.isCallExpression(node)) { @@ -35,29 +66,58 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. (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; + let spyCall: ts.Expression | undefined; + if ( ts.isPropertyAccessExpression(pae.expression) && ts.isIdentifier(pae.expression.name) && pae.expression.name.text === 'and' ) { - const spyCall = pae.expression.expression; + spyCall = pae.expression.expression; + } else if ( + ts.isElementAccessExpression(pae.expression) && + ts.isStringLiteralLike(pae.expression.argumentExpression) && + pae.expression.argumentExpression.text === 'and' + ) { + spyCall = pae.expression.expression; + } + + if (spyCall) { let newMethodName: string | undefined; let args = node.arguments; @@ -66,7 +126,8 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. switch (strategyName) { case 'returnValue': { - const result = getPromiseResolveRejectMethod(args[0]); + const firstArg = args[0]; + const result = firstArg ? getPromiseResolveRejectMethod(firstArg) : null; if (result) { const methodMapping = { 'resolve': 'mockResolvedValue', @@ -146,12 +207,12 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ); const errorArg = node.arguments[0]; const throwStatement = ts.factory.createThrowStatement( - ts.isNewExpression(errorArg) + errorArg && ts.isNewExpression(errorArg) ? errorArg : ts.factory.createNewExpression( ts.factory.createIdentifier('Error'), undefined, - node.arguments, + errorArg ? [errorArg] : [], ), ); const arrowFunction = ts.factory.createArrowFunction( 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 }) => { From 1954ddbaf119d7db880babce171d17ebe8016a2f Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 28 May 2026 11:52:59 -0400 Subject: [PATCH 2/2] refactor(@schematics/angular): decompose transformSpies into modular helper functions --- .../transformers/jasmine-spy.ts | 323 ++++++++++-------- 1 file changed, 175 insertions(+), 148 deletions(-) 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 10d7c66e09b3..740628d41fcd 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -54,13 +54,8 @@ function isChainedWithAnd(node: ts.Node): boolean { return false; } -/* eslint-disable max-lines-per-function */ -export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node { +function transformPrimarySpy(node: ts.CallExpression, refactorCtx: RefactorContext): ts.Node { const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx; - if (!ts.isCallExpression(node)) { - return node; - } - if ( ts.isIdentifier(node.expression) && (node.expression.text === 'spyOn' || node.expression.text === 'spyOnProperty') @@ -98,172 +93,186 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ); } - if (ts.isPropertyAccessExpression(node.expression)) { - const pae = node.expression; + return node; +} - let spyCall: ts.Expression | undefined; +function transformSpyStrategy(node: ts.CallExpression, refactorCtx: RefactorContext): ts.Node { + const { sourceFile, reporter } = refactorCtx; + if (!ts.isPropertyAccessExpression(node.expression)) { + return node; + } - 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; - } + const pae = node.expression; + let spyCall: ts.Expression | undefined; - 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'; - } - } - 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; - } + 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 chainedCall; + 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 'callFake': - newMethodName = 'mockImplementation'; - break; - case 'callThrough': - reporter.reportTransformation( - sourceFile, - node, - 'Removed redundant `.and.callThrough()` call.', - ); - - 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), - ); - - return ts.factory.createCallExpression(newExpression, undefined, [arrowFn]); + 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); } - 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( + // 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, - undefined, - [], - 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.', + ); + + 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 (newMethodName) { + return ts.factory.createCallExpression(newExpression, undefined, [arrowFn]); + } + case 'throwError': { reporter.reportTransformation( sourceFile, node, - `Transformed spy strategy \`.and.${strategyName}()\` to \`.${newMethodName}()\`.`, + '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, @@ -280,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')) {