From 82208d6a99740027014cf2ac2f15c4771c65c448 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:58:51 -0500 Subject: [PATCH 1/3] fix(@schematics/angular): add mock names to createSpyObj transformation This commit enhances the jasmine-vitest schematic by adding mock names to `vi.fn()` instances created from `jasmine.createSpyObj`. This improves debugging in Vitest by providing meaningful names for mock functions, making test failures easier to understand and trace. --- .../test-file-transformer.integration_spec.ts | 6 ++- .../test-file-transformer_spec.ts | 6 +-- .../transformers/jasmine-spy.ts | 51 +++++++++++++++---- .../transformers/jasmine-spy_spec.ts | 20 ++++---- 4 files changed, 57 insertions(+), 26 deletions(-) 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 ebc7e1631907..0fe0d7c5b3e2 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 @@ -255,14 +255,15 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { }); `; + /* eslint-disable max-len */ const vitestCode = ` describe('Complex Scenarios', () => { let serviceMock; beforeEach(() => { serviceMock = { - getData: vi.fn().mockReturnValue(expect.any(String)), - process: vi.fn().mockReturnValue(undefined), + getData: vi.fn().mockName("MyService.getData").mockReturnValue(expect.any(String)), + process: vi.fn().mockName("MyService.process").mockReturnValue(undefined), }; }); @@ -299,6 +300,7 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { }); }); `; + /* eslint-enable max-len */ await expectTransformation(jasmineCode, vitestCode); }); 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 bc55811306b3..45580be66476 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 @@ -78,7 +78,7 @@ describe('Jasmine to Vitest Transformer', () => { input: `const spy = jasmine.createSpyObj('MyService', { getPromise: Promise.resolve(jasmine.any(String)) });`, expected: ` const spy = { - getPromise: vi.fn().mockReturnValue(Promise.resolve(expect.any(String))), + getPromise: vi.fn().mockName("MyService.getPromise").mockReturnValue(Promise.resolve(expect.any(String))), }; `, }, @@ -105,8 +105,8 @@ describe('Jasmine to Vitest Transformer', () => { `, expected: ` const myService = { - methodA: vi.fn(), - propA: 'valueA' + methodA: vi.fn().mockName("MyService.methodA"), + propA: 'valueA', }; vi.spyOn(myService, 'methodA').mockReturnValue('mocked value'); myService.methodA('test'); 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 d2d9af5f01a6..c10fc739f9af 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -227,6 +227,12 @@ export function transformCreateSpyObj( 'Transformed `jasmine.createSpyObj()` to an object literal with `vi.fn()`.', ); + const baseNameArg = node.arguments[0]; + const baseName = ts.isStringLiteral(baseNameArg) ? baseNameArg.text : undefined; + const methods = node.arguments[1]; + const propertiesArg = node.arguments[2]; + let properties: ts.PropertyAssignment[] = []; + if (node.arguments.length < 2) { const category = 'createSpyObj-single-argument'; reporter.recordTodo(category); @@ -235,14 +241,10 @@ export function transformCreateSpyObj( return node; } - const methods = node.arguments[1]; - const propertiesArg = node.arguments[2]; - let properties: ts.PropertyAssignment[] = []; - if (ts.isArrayLiteralExpression(methods)) { - properties = createSpyObjWithArray(methods); + properties = createSpyObjWithArray(methods, baseName); } else if (ts.isObjectLiteralExpression(methods)) { - properties = createSpyObjWithObject(methods); + properties = createSpyObjWithObject(methods, baseName); } else { const category = 'createSpyObj-dynamic-variable'; reporter.recordTodo(category); @@ -264,13 +266,28 @@ export function transformCreateSpyObj( return ts.factory.createObjectLiteralExpression(properties, true); } -function createSpyObjWithArray(methods: ts.ArrayLiteralExpression): ts.PropertyAssignment[] { +function createSpyObjWithArray( + methods: ts.ArrayLiteralExpression, + baseName: string | undefined, +): ts.PropertyAssignment[] { return methods.elements .map((element) => { if (ts.isStringLiteral(element)) { + const mockFn = createViCallExpression('fn'); + const methodName = element.text; + let finalExpression: ts.Expression = mockFn; + + if (baseName) { + finalExpression = ts.factory.createCallExpression( + createPropertyAccess(finalExpression, 'mockName'), + undefined, + [ts.factory.createStringLiteral(`${baseName}.${methodName}`)], + ); + } + return ts.factory.createPropertyAssignment( - ts.factory.createIdentifier(element.text), - createViCallExpression('fn'), + ts.factory.createIdentifier(methodName), + finalExpression, ); } @@ -279,13 +296,25 @@ function createSpyObjWithArray(methods: ts.ArrayLiteralExpression): ts.PropertyA .filter((p): p is ts.PropertyAssignment => !!p); } -function createSpyObjWithObject(methods: ts.ObjectLiteralExpression): ts.PropertyAssignment[] { +function createSpyObjWithObject( + methods: ts.ObjectLiteralExpression, + baseName: string | undefined, +): ts.PropertyAssignment[] { return methods.properties .map((prop) => { if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { const methodName = prop.name.text; const returnValue = prop.initializer; - const mockFn = createViCallExpression('fn'); + let mockFn = createViCallExpression('fn'); + + if (baseName) { + mockFn = ts.factory.createCallExpression( + createPropertyAccess(mockFn, 'mockName'), + undefined, + [ts.factory.createStringLiteral(`${baseName}.${methodName}`)], + ); + } + const mockReturnValue = createPropertyAccess(mockFn, 'mockReturnValue'); return ts.factory.createPropertyAssignment( 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 c84b35c422ac..3fc98aa02601 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 @@ -118,8 +118,8 @@ vi.spyOn(service, 'myMethod').and.unknownStrategy();`, description: 'should transform jasmine.createSpyObj with an array of methods', input: `const myService = jasmine.createSpyObj('MyService', ['methodA', 'methodB']);`, expected: `const myService = { - methodA: vi.fn(), - methodB: vi.fn() + methodA: vi.fn().mockName("MyService.methodA"), + methodB: vi.fn().mockName("MyService.methodB"), };`, }, { @@ -134,8 +134,8 @@ vi.spyOn(service, 'myMethod').and.unknownStrategy();`, description: 'should transform jasmine.createSpyObj with an object of return values', input: `const myService = jasmine.createSpyObj('MyService', { methodA: 'foo', methodB: 42 });`, expected: `const myService = { - methodA: vi.fn().mockReturnValue('foo'), - methodB: vi.fn().mockReturnValue(42) + methodA: vi.fn().mockName("MyService.methodA").mockReturnValue('foo'), + methodB: vi.fn().mockName("MyService.methodB").mockReturnValue(42), };`, }, { @@ -143,7 +143,7 @@ vi.spyOn(service, 'myMethod').and.unknownStrategy();`, 'should transform jasmine.createSpyObj with an object of return values containing an asymmetric matcher', input: `const myService = jasmine.createSpyObj('MyService', { methodA: jasmine.any(String) });`, expected: `const myService = { - methodA: vi.fn().mockReturnValue(expect.any(String)) + methodA: vi.fn().mockName("MyService.methodA").mockReturnValue(expect.any(String)), };`, }, { @@ -158,23 +158,23 @@ vi.spyOn(service, 'myMethod').and.unknownStrategy();`, description: 'should transform jasmine.createSpyObj with a property map', input: `const myService = jasmine.createSpyObj('MyService', ['methodA'], { propA: 'valueA' });`, expected: `const myService = { - methodA: vi.fn(), - propA: 'valueA' + methodA: vi.fn().mockName("MyService.methodA"), + propA: 'valueA', };`, }, { description: 'should transform jasmine.createSpyObj with a method map and a property map', input: `const myService = jasmine.createSpyObj('MyService', { methodA: 'foo' }, { propA: 'valueA' });`, expected: `const myService = { - methodA: vi.fn().mockReturnValue('foo'), - propA: 'valueA' + methodA: vi.fn().mockName("MyService.methodA").mockReturnValue('foo'), + propA: 'valueA', };`, }, { description: 'should ignore non-string literals in the method array', input: `const myService = jasmine.createSpyObj('MyService', ['methodA', 123, someVar]);`, expected: `const myService = { - methodA: vi.fn() + methodA: vi.fn().mockName("MyService.methodA"), };`, }, ]; From a67ee30aa4fbdb603894f4beacb73b37654605ea Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:04:42 -0500 Subject: [PATCH 2/3] fix(@schematics/angular): improve safety of done callback transformation This commit adds a safety check to the `done` callback transformation in the `jasmine-vitest` schematic. Previously, if a `done` callback was used in an unhandled way (e.g., passed as an argument to a helper function), the schematic would remove the `done` parameter but leave the usage, causing runtime errors. Now, the transformer counts the total usages of the `done` callback and compares it to the number of usages it successfully transformed. If there are unhandled usages, it aborts the transformation for that specific test and adds a TODO comment, ensuring that no broken code is generated. --- .../transformers/jasmine-lifecycle.ts | 35 +++++++++++++++++++ .../transformers/jasmine-lifecycle_spec.ts | 15 ++++++++ .../jasmine-vitest/utils/todo-notes.ts | 3 ++ 3 files changed, 53 insertions(+) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts index 235eef129d11..f2b60c35f327 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts @@ -282,6 +282,19 @@ function transformPromiseBasedDone( return undefined; } +function countDoneUsages(node: ts.Node, doneIdentifier: ts.Identifier): number { + let count = 0; + const visitor = (n: ts.Node) => { + if (ts.isIdentifier(n) && n.text === doneIdentifier.text) { + count++; + } + ts.forEachChild(n, visitor); + }; + ts.forEachChild(node, visitor); + + return count; +} + export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContext): ts.Node { const { sourceFile, reporter, tsContext } = refactorCtx; if ( @@ -309,12 +322,17 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex return node; } const doneIdentifier = doneParam.name; + + // Count total usages of 'done' in the body + const totalUsages = countDoneUsages(functionArg.body, doneIdentifier); + let handledUsages = 0; let doneWasUsed = false; const bodyVisitor = (bodyNode: ts.Node): ts.Node | ts.Node[] | undefined => { const complexTransformed = transformComplexDoneCallback(bodyNode, doneIdentifier, refactorCtx); if (complexTransformed !== bodyNode) { doneWasUsed = true; + handledUsages++; // complex transform handles one usage return complexTransformed; } @@ -330,6 +348,7 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex callExpr.expression.name.text === 'fail' ) { doneWasUsed = true; + handledUsages++; reporter.reportTransformation( sourceFile, bodyNode, @@ -350,6 +369,7 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex const promiseTransformed = transformPromiseBasedDone(callExpr, doneIdentifier, refactorCtx); if (promiseTransformed) { doneWasUsed = true; + handledUsages++; return promiseTransformed; } @@ -360,6 +380,7 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex callExpr.expression.text === doneIdentifier.text ) { doneWasUsed = true; + handledUsages++; return ts.setTextRange(ts.factory.createEmptyStatement(), callExpr.expression); } @@ -383,6 +404,20 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex return bodyVisitor(node); }); + // Safety check: if we found usages but didn't handle all of them, abort. + if (handledUsages < totalUsages) { + reporter.reportTransformation( + sourceFile, + node, + `Found unhandled usage of \`${doneIdentifier.text}\` callback. Skipping transformation.`, + ); + const category = 'unhandled-done-usage'; + reporter.recordTodo(category); + addTodoComment(node, category); + + return node; + } + if (!doneWasUsed) { return node; } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle_spec.ts index d5f9e3231180..f41f5fa7ae32 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle_spec.ts @@ -132,6 +132,7 @@ describe('Jasmine to Vitest Transformer', () => { }); `, expected: ` + // TODO: vitest-migration: The 'done' callback was used in an unhandled way. Please migrate manually. it('should not transform a function with a parameter that is not a done callback', (value) => { expect(value).toBe(true); }); @@ -157,6 +158,20 @@ describe('Jasmine to Vitest Transformer', () => { }); `, }, + { + description: 'should add a TODO for unhandled done usage', + input: ` + it('should do something with helper', (done) => { + someHelper(done); + }); + `, + expected: ` + // TODO: vitest-migration: The 'done' callback was used in an unhandled way. Please migrate manually. + it('should do something with helper', (done) => { + someHelper(done); + }); + `, + }, ]; testCases.forEach(({ description, input, expected }) => { 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 c0a5eb406e6a..3f43a6e4f838 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -120,6 +120,9 @@ 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', }, + 'unhandled-done-usage': { + message: "The 'done' callback was used in an unhandled way. Please migrate manually.", + }, } as const; /** From 9223c271f74c5075bdf1e7660778bed8803d84e1 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:13:07 -0500 Subject: [PATCH 3/3] fix(@schematics/angular): warn about loose matching in arrayWithExactContents This commit improves the transformation of `jasmine.arrayWithExactContents` in the `jasmine-vitest` schematic. While Vitest does not have a direct equivalent for multiset equality, the schematic previously transformed this into a check for length and subset containment, which could lead to false positives (e.g., `[1, 1]` matches `[1, 2]`). Now, the schematic attaches a TODO comment to the generated code, explicitly warning the user that the transformation relies on `expect.arrayContaining` (a subset check) and advising them to verify strict content matching manually. --- .../test-file-transformer.integration_spec.ts | 1 + .../jasmine-vitest/test-file-transformer_spec.ts | 3 +++ .../jasmine-vitest/transformers/jasmine-matcher.ts | 12 ++++++++---- .../transformers/jasmine-matcher_spec.ts | 6 ++++++ .../refactor/jasmine-vitest/utils/todo-notes.ts | 4 ++++ 5 files changed, 22 insertions(+), 4 deletions(-) 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 0fe0d7c5b3e2..8944e34dfa14 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 @@ -275,6 +275,7 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { it('should handle array contents checking', () => { const arr = [1, 2, 3]; + // TODO: vitest-migration: Verify this matches strict array content (multiset equality). Vitest's arrayContaining is a subset check. expect(arr).toHaveLength(3); expect(arr).toEqual(expect.arrayContaining([3, 2, 1])); }); 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 45580be66476..abe1f3655cdd 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 @@ -85,10 +85,13 @@ describe('Jasmine to Vitest Transformer', () => { { description: 'should handle arrayWithExactContents containing nested asymmetric matchers', input: `expect(myArray).toEqual(jasmine.arrayWithExactContents([jasmine.objectContaining({ id: 1 })]));`, + /* eslint-disable max-len */ expected: ` + // TODO: vitest-migration: Verify this matches strict array content (multiset equality). Vitest's arrayContaining is a subset check. expect(myArray).toHaveLength(1); expect(myArray).toEqual(expect.arrayContaining([expect.objectContaining({ id: 1 })])); `, + /* eslint-enable max-len */ }, { description: 'should handle a spy rejecting with an asymmetric matcher', 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 b1b746aad504..5de173bcd52b 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -451,10 +451,14 @@ export function transformArrayWithExactContents( ], ); - return [ - ts.factory.createExpressionStatement(lengthCall), - ts.factory.createExpressionStatement(containingCall), - ]; + const lengthStmt = ts.factory.createExpressionStatement(lengthCall); + const containingStmt = ts.factory.createExpressionStatement(containingCall); + + const category = 'arrayWithExactContents-check'; + reporter.recordTodo(category); + addTodoComment(lengthStmt, category); + + return [lengthStmt, containingStmt]; } export function transformCalledOnceWith( 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 69b0637254aa..a46fc63d2a01 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 @@ -225,19 +225,25 @@ expect(mySpyObj).toHaveSpyInteractions();`, { description: 'should transform toEqual(jasmine.arrayWithExactContents()) into two calls', input: `expect(myArray).toEqual(jasmine.arrayWithExactContents(['a', 'b']));`, + /* eslint-disable max-len */ expected: ` + // TODO: vitest-migration: Verify this matches strict array content (multiset equality). Vitest's arrayContaining is a subset check. expect(myArray).toHaveLength(2); expect(myArray).toEqual(expect.arrayContaining(['a', 'b'])); `, + /* eslint-enable max-len */ }, { description: 'should transform toEqual(jasmine.arrayWithExactContents()) with asymmetric matchers', input: `expect(myArray).toEqual(jasmine.arrayWithExactContents([jasmine.any(Number), 'a']));`, + /* eslint-disable max-len */ expected: ` + // TODO: vitest-migration: Verify this matches strict array content (multiset equality). Vitest's arrayContaining is a subset check. expect(myArray).toHaveLength(2); expect(myArray).toEqual(expect.arrayContaining([expect.any(Number), 'a'])); `, + /* eslint-enable max-len */ }, { description: 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 3f43a6e4f838..a4964b1e456b 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -56,6 +56,10 @@ export const TODO_NOTES = { message: 'Cannot transform jasmine.arrayWithExactContents with a dynamic variable. Please migrate this manually.', }, + 'arrayWithExactContents-check': { + message: + "Verify this matches strict array content (multiset equality). Vitest's arrayContaining is a subset check.", + }, 'expect-nothing': { message: 'expect().nothing() has been removed because it is redundant in Vitest. Tests without assertions pass by default.',