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 68a76b378194..a5a90201de58 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -16,6 +16,7 @@ import ts from 'typescript'; import { + addVitestTypeImport, addVitestValueImport, createPropertyAccess, getPromiseResolveRejectMethod, @@ -459,7 +460,96 @@ export function transformCreateSpyObj(node: ts.Node, ctx: RefactorContext): ts.N } } - return ts.factory.createObjectLiteralExpression(properties, true); + const spyObjLiteral = ts.factory.createObjectLiteralExpression(properties, true); + + // The literal only has the spied members, so it is not assignable to the `MockedObject` + // that a `jasmine.SpyObj` annotation is transformed to. Jasmine's untyped `createSpyObj` + // returns `any`, which is why the original assignment compiled. + const spyObjTypeArguments = getAssignedSpyObjTypeArguments(node); + if (!spyObjTypeArguments) { + return spyObjLiteral; + } + + addVitestTypeImport(ctx.pendingVitestTypeImports, 'MockedObject'); + + return ts.factory.createAsExpression( + spyObjLiteral, + ts.factory.createTypeReferenceNode('MockedObject', spyObjTypeArguments), + ); +} + +function getSpyObjTypeArguments( + type: ts.TypeNode | undefined, +): ts.NodeArray | undefined { + if ( + type && + ts.isTypeReferenceNode(type) && + ts.isQualifiedName(type.typeName) && + ts.isIdentifier(type.typeName.left) && + type.typeName.left.text === 'jasmine' && + type.typeName.right.text === 'SpyObj' + ) { + return type.typeArguments; + } + + return undefined; +} + +/** + * Finds the declaration of a variable in the blocks that enclose a node, nearest first, + * so that a variable declared in another `describe` block with the same name is not used. + */ +function findEnclosingVariableDeclaration( + node: ts.Node, + name: string, +): ts.VariableDeclaration | undefined { + for (let current = node.parent; current; current = current.parent) { + if (!ts.isBlock(current) && !ts.isSourceFile(current)) { + continue; + } + + for (const statement of current.statements) { + if (!ts.isVariableStatement(statement)) { + continue; + } + + const declaration = statement.declarationList.declarations.find( + (d) => ts.isIdentifier(d.name) && d.name.text === name, + ); + if (declaration) { + return declaration; + } + } + } + + return undefined; +} + +/** + * Finds the `T` of a `jasmine.SpyObj` annotation on the variable that a `createSpyObj` call + * initializes (`const spy: jasmine.SpyObj = ...`) or is assigned to (`spy = ...`). + */ +function getAssignedSpyObjTypeArguments( + node: ts.CallExpression, +): ts.NodeArray | undefined { + const parent = getUnwrappedParent(node); + if (!parent) { + return undefined; + } + + if (ts.isVariableDeclaration(parent)) { + return getSpyObjTypeArguments(parent.type); + } + + if ( + !ts.isBinaryExpression(parent) || + parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isIdentifier(parent.left) + ) { + return undefined; + } + + return getSpyObjTypeArguments(findEnclosingVariableDeclaration(parent, parent.left.text)?.type); } function createSpyObjWithArray( 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 e14de03c3c17..51fe8fc89bd8 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 @@ -187,6 +187,71 @@ vi.spyOn(service, 'myMethod').and.unknownStrategy();`, describe('transformCreateSpyObj', () => { const testCases = [ + { + description: + 'should cast jasmine.createSpyObj to MockedObject when assigned to a jasmine.SpyObj variable', + input: ` + let myService: jasmine.SpyObj; + myService = jasmine.createSpyObj('MyService', ['methodA']); + `, + expected: ` + import type { MockedObject } from 'vitest'; + + let myService: MockedObject; + myService = { + methodA: vi.fn().mockName('MyService.methodA'), + } as MockedObject; + `, + }, + { + description: + 'should cast jasmine.createSpyObj to MockedObject when it initializes a jasmine.SpyObj variable', + input: `const myService: jasmine.SpyObj = jasmine.createSpyObj('MyService', ['methodA']);`, + expected: ` + import type { MockedObject } from 'vitest'; + const myService: MockedObject = { + methodA: vi.fn().mockName('MyService.methodA'), + } as MockedObject; + `, + }, + { + description: + 'should cast jasmine.createSpyObj to the jasmine.SpyObj type of the variable in the enclosing describe block', + input: ` + describe('Foo', () => { + let svc: jasmine.SpyObj; + beforeEach(() => { + svc = jasmine.createSpyObj('Foo', ['a']); + }); + }); + describe('Bar', () => { + let svc: jasmine.SpyObj; + beforeEach(() => { + svc = jasmine.createSpyObj('Bar', ['b']); + }); + }); + `, + expected: ` + import type { MockedObject } from 'vitest'; + + describe('Foo', () => { + let svc: MockedObject; + beforeEach(() => { + svc = { + a: vi.fn().mockName('Foo.a'), + } as MockedObject; + }); + }); + describe('Bar', () => { + let svc: MockedObject; + beforeEach(() => { + svc = { + b: vi.fn().mockName('Bar.b'), + } as MockedObject; + }); + }); + `, + }, { description: 'should transform jasmine.createSpyObj with an array of methods', input: `const myService = jasmine.createSpyObj('MyService', ['methodA', 'methodB']);`,