From f9ffa24a4122ea0a41187133bc3a2d43d0e82a94 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Tue, 17 Mar 2026 14:21:39 +0100 Subject: [PATCH 1/5] feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../angular/refactor/jasmine-vitest/index.ts | 1 + .../refactor/jasmine-vitest/schema.json | 5 + .../test-file-transformer.integration_spec.ts | 64 ++++- .../jasmine-vitest/test-file-transformer.ts | 44 +++- .../test-file-transformer_add-imports_spec.ts | 28 +++ .../refactor/jasmine-vitest/test-helpers.ts | 1 + .../fake-async-flush-microtasks.ts | 38 +++ .../fake-async-flush-microtasks_spec.ts | 43 ++++ .../transformers/fake-async-flush.ts | 60 +++++ .../transformers/fake-async-flush_spec.ts | 108 +++++++++ .../transformers/fake-async-test.ts | 214 +++++++++++++++++ .../transformers/fake-async-test_spec.ts | 224 ++++++++++++++++++ .../transformers/fake-async-tick.ts | 43 ++++ .../transformers/fake-async-tick_spec.ts | 51 ++++ .../transformers/jasmine-misc.ts | 16 +- .../transformers/jasmine-spy.ts | 19 +- .../jasmine-vitest/utils/ast-helpers.ts | 124 ++++++++-- .../jasmine-vitest/utils/constants.ts | 9 + .../jasmine-vitest/utils/refactor-context.ts | 6 + .../jasmine-vitest/utils/refactor-helpers.ts | 65 +++++ .../jasmine-vitest/utils/todo-notes.ts | 7 + 21 files changed, 1130 insertions(+), 40 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index 4ae4077a7be4..493bb0eb1800 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -122,6 +122,7 @@ export default function (options: Schema): Rule { const newContent = transformJasmineToVitest(file, content, reporter, { addImports: !!options.addImports, browserMode: !!options.browerMode, + fakeAsync: !!options.fakeAsync, }); if (content !== newContent) { diff --git a/packages/schematics/angular/refactor/jasmine-vitest/schema.json b/packages/schematics/angular/refactor/jasmine-vitest/schema.json index 4192a27367fd..5b23618cbc48 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/schema.json +++ b/packages/schematics/angular/refactor/jasmine-vitest/schema.json @@ -36,6 +36,11 @@ "description": "Whether the tests are intended to run in browser mode. If true, the `toHaveClass` assertions are left as is because Vitest browser mode has such an assertion. Otherwise they're migrated to an equivalent assertion.", "default": false }, + "fakeAsync": { + "type": "boolean", + "description": "Whether to transform `fakeAsync` tests to Vitest fake timers.", + "default": false + }, "report": { "type": "boolean", "description": "Whether to generate a summary report file (jasmine-vitest-.md) in the project root.", 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 2636a142d4b6..016c8d9fe1d4 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 @@ -14,7 +14,7 @@ import { RefactorReporter } from './utils/refactor-reporter'; async function expectTransformation( input: string, expected: string, - options: { addImports: boolean; browserMode: boolean } = { + options: { addImports: boolean; browserMode: boolean; fakeAsync?: boolean } = { addImports: false, browserMode: false, }, @@ -534,4 +534,66 @@ describe('Jasmine to Vitest Transformer - Integration Tests', () => { await expectTransformation(jasmineCode, vitestCode); }); + + it('should not transform `fakeAsync`', async () => { + const jasmineCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + const vitestCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + + await expectTransformation(jasmineCode, vitestCode); + }); + + it('should transform `fakeAsync` if `fakeAsync` option is true', async () => { + const jasmineCode = ` + import { fakeAsync, flush, flushMicrotasks, tick } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + flush(); + flushMicrotasks(); + tick(100); + })); + }); + `; + const vitestCode = ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(100); + }); + }); + `; + + await expectTransformation(jasmineCode, vitestCode, { + addImports: false, + browserMode: false, + fakeAsync: true, + }); + }); }); 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 de80052d0b2a..f652368b03f7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -14,6 +14,10 @@ */ import ts from 'typescript'; +import { transformFakeAsyncFlush } from './transformers/fake-async-flush'; +import { transformFakeAsyncFlushMicrotasks } from './transformers/fake-async-flush-microtasks'; +import { transformFakeAsyncTest } from './transformers/fake-async-test'; +import { transformFakeAsyncTick } from './transformers/fake-async-tick'; import { transformDoneCallback, transformFocusedAndSkippedTests, @@ -48,7 +52,11 @@ import { transformSpyReset, } from './transformers/jasmine-spy'; import { transformJasmineTypes } from './transformers/jasmine-type'; -import { addVitestValueImport, getVitestAutoImports } from './utils/ast-helpers'; +import { + addVitestValueImport, + getVitestAutoImports, + removeImportSpecifiers, +} from './utils/ast-helpers'; import { RefactorContext } from './utils/refactor-context'; import { RefactorReporter } from './utils/refactor-reporter'; @@ -129,6 +137,10 @@ const callExpressionTransformers = [ transformToHaveBeenCalledBefore, transformToHaveClass, transformToBeNullish, + transformFakeAsyncTest, + transformFakeAsyncTick, + transformFakeAsyncFlush, + transformFakeAsyncFlushMicrotasks, // **Stage 3: Global Functions & Cleanup** // These handle global Jasmine functions and catch-alls for unsupported APIs. @@ -173,8 +185,10 @@ export function transformJasmineToVitest( filePath: string, content: string, reporter: RefactorReporter, - options: { addImports: boolean; browserMode: boolean }, + options: { addImports: boolean; browserMode: boolean; fakeAsync?: boolean }, ): string { + options.fakeAsync ??= false; + const contentWithPlaceholders = preserveBlankLines(content); const sourceFile = ts.createSourceFile( @@ -187,6 +201,7 @@ export function transformJasmineToVitest( const pendingVitestValueImports = new Set(); const pendingVitestTypeImports = new Set(); + const pendingImportSpecifierRemovals = new Map>(); const transformer: ts.TransformerFactory = (context) => { const refactorCtx: RefactorContext = { @@ -195,6 +210,7 @@ export function transformJasmineToVitest( tsContext: context, pendingVitestValueImports, pendingVitestTypeImports, + pendingImportSpecifierRemovals, }; const visitor: ts.Visitor = (node) => { @@ -211,7 +227,18 @@ export function transformJasmineToVitest( } for (const transformer of callExpressionTransformers) { - if (!(options.browserMode && transformer === transformToHaveClass)) { + if ( + !( + (options.browserMode && transformer === transformToHaveClass) || + (options.fakeAsync === false && + [ + transformFakeAsyncFlush, + transformFakeAsyncFlushMicrotasks, + transformFakeAsyncTick, + transformFakeAsyncTest, + ].includes(transformer)) + ) + ) { transformedNode = transformer(transformedNode, refactorCtx); } } @@ -249,16 +276,25 @@ export function transformJasmineToVitest( const hasPendingValueImports = pendingVitestValueImports.size > 0; const hasPendingTypeImports = pendingVitestTypeImports.size > 0; + const hasPendingImportSpecifierRemovals = pendingImportSpecifierRemovals.size > 0; if ( transformedSourceFile === sourceFile && !reporter.hasTodos && !hasPendingValueImports && - !hasPendingTypeImports + !hasPendingTypeImports && + !hasPendingImportSpecifierRemovals ) { return content; } + if (hasPendingImportSpecifierRemovals) { + transformedSourceFile = removeImportSpecifiers( + transformedSourceFile, + pendingImportSpecifierRemovals, + ); + } + if (hasPendingTypeImports || (options.addImports && hasPendingValueImports)) { const vitestImport = getVitestAutoImports( options.addImports ? pendingVitestValueImports : new Set(), 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 2eaca1f5bf15..c835dc9640c5 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 @@ -150,4 +150,32 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { true, ); }); + + it('should add imports for `vi` when addImports is true', async () => { + const input = ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `; + const expected = ` + import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + expect(1).toBe(1); + }); + }); + `; + await expectTransformation(input, expected, true); + }); }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts b/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts index 9aa6532206da..6986c4c39d0c 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts @@ -33,6 +33,7 @@ export async function expectTransformation( const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports, browserMode: false, + fakeAsync: true, }); const formattedTransformed = await format(transformed, { parser: 'typescript' }); const formattedExpected = await format(expected, { parser: 'typescript' }); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts new file mode 100644 index 000000000000..59064bce4f18 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncFlushMicrotasks(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flushMicrotasks' && + isNamedImportFrom(ctx.sourceFile, 'flushMicrotasks', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, + ); + + addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); + + return ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [ts.factory.createNumericLiteral(0)]), + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts new file mode 100644 index 000000000000..16f9eb8e88e2 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks_spec.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncFlushMicrotasks', () => { + const testCases = [ + { + description: 'should replace `flushMicrotasks` with `await vi.advanceTimersByTimeAsync(0)`', + input: ` + import { flushMicrotasks } from '@angular/core/testing'; + + flushMicrotasks(); + `, + expected: `await vi.advanceTimersByTimeAsync(0);`, + }, + { + description: + 'should not replace `flushMicrotasks` if not imported from `@angular/core/testing`', + input: ` + import { flushMicrotasks } from './my-flush-microtasks'; + + flushMicrotasks(); + `, + expected: ` + import { flushMicrotasks } from './my-flush-microtasks'; + + flushMicrotasks(); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts new file mode 100644 index 000000000000..5235ea8e1abf --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { addTodoComment } from '../utils/comment-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flush' && + isNamedImportFrom(ctx.sourceFile, 'flush', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, + ); + + addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); + + if (node.arguments.length > 0) { + ctx.reporter.recordTodo('flush-max-turns', ctx.sourceFile, node); + addTodoComment(node, 'flush-max-turns'); + } + + const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'runAllTimersAsync'), + ); + + if (ts.isExpressionStatement(node.parent)) { + return awaitRunAllTimersAsync; + } else { + // If `flush` is not used as its own statement, then the return value is probably used. + // Therefore, we replace it with nullish coalescing that returns 0: + // > await vi.runAllTimersAsync() ?? 0; + ctx.reporter.recordTodo('flush-return-value', ctx.sourceFile, node); + addTodoComment(node, 'flush-return-value'); + + return ts.factory.createBinaryExpression( + awaitRunAllTimersAsync, + ts.SyntaxKind.QuestionQuestionToken, + ts.factory.createNumericLiteral(0), + ); + } +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts new file mode 100644 index 000000000000..1daeda891461 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncFlush', () => { + const testCases = [ + { + description: 'should replace `flush` with `await vi.runAllTimersAsync()`', + input: ` + import { flush } from '@angular/core/testing'; + + flush(); + `, + expected: `await vi.runAllTimersAsync();`, + }, + { + description: 'should add TODO comment when flush is called with maxTurns', + input: ` + import { flush } from '@angular/core/testing'; + + flush(42); + `, + expected: ` + // TODO: vitest-migration: flush(maxTurns) was called but maxTurns parameter is not migrated. Please migrate manually. + await vi.runAllTimersAsync(); + `, + }, + { + description: 'should add TODO comment when flush return value is used', + input: ` + import { flush } from '@angular/core/testing'; + + const turns = flush(); + `, + expected: ` + // TODO: vitest-migration: flush() return value is not migrated. Please migrate manually. + const turns = await vi.runAllTimersAsync() ?? 0; + `, + }, + { + description: 'should add TODO comment when flush return value is used in a return statement', + input: ` + import { flush } from '@angular/core/testing'; + + async function myFlushWrapper() { + return flush(); + } + `, + expected: ` + async function myFlushWrapper() { + // TODO: vitest-migration: flush() return value is not migrated. Please migrate manually. + return await vi.runAllTimersAsync() ?? 0; + } + `, + }, + { + description: 'should not replace `flush` if not imported from `@angular/core/testing`', + input: ` + import { flush } from './my-flush'; + + flush(); + `, + expected: ` + import { flush } from './my-flush'; + + flush(); + `, + }, + { + description: 'should keep other imported symbols from `@angular/core/testing`', + input: ` + import { TestBed, flush } from '@angular/core/testing'; + + flush(); + `, + expected: ` + import { TestBed } from '@angular/core/testing'; + + await vi.runAllTimersAsync(); + `, + }, + { + description: 'should keep imported types from `@angular/core/testing`', + input: ` + import { flush, type ComponentFixture } from '@angular/core/testing'; + + flush(); + `, + expected: ` + import { type ComponentFixture } from '@angular/core/testing'; + + await vi.runAllTimersAsync(); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts new file mode 100644 index 000000000000..ba2161687eb6 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncTest( + node: ts.Node, + ctx: RefactorContext, + currentOutermostDescribeContext?: CurrentOutermostDescribeContext, +): ts.Node { + // Transform the outermost describe block and skip others. + if (currentOutermostDescribeContext == null && _is.describe(node)) { + return _transformDescribeCall(node, ctx); + } + + // If we encounter a `fakeAsync` call while in a `describe` block, mark it in the context. + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + currentOutermostDescribeContext != null && + node.arguments.length >= 1 && + _is.arrowOrFunction(node.arguments[0]) && + isNamedImportFrom(ctx.sourceFile, 'fakeAsync', ANGULAR_CORE_TESTING) + ) { + return _transformFakeAsyncCall(node, ctx, currentOutermostDescribeContext); + } + + // If we are in a `describe` block, visit the children recursively. + if (currentOutermostDescribeContext != null) { + return ts.visitEachChild( + node, + (child) => transformFakeAsyncTest(child, ctx, currentOutermostDescribeContext), + ctx.tsContext, + ); + } + + return node; +} + +function _transformDescribeCall(node: ts.CallExpression, ctx: RefactorContext): ts.CallExpression { + const currentOutermostDescribeContext: CurrentOutermostDescribeContext = { + isUsingFakeAsync: false, + }; + + // Visit children recursively to collect transform `fakeAsync usages + // within the describe block and detect their presence through `isUsingFakeAsync`. + node = ts.visitEachChild( + node, + (child) => transformFakeAsyncTest(child, ctx, currentOutermostDescribeContext), + ctx.tsContext, + ); + + const { isUsingFakeAsync } = currentOutermostDescribeContext; + + const describeBlock = _findDescribeBlock(node); + if (!isUsingFakeAsync || describeBlock === undefined) { + return node; + } + + addImportSpecifierRemoval(ctx, 'fakeAsync', ANGULAR_CORE_TESTING); + + return ts.factory.updateCallExpression(node, node.expression, node.typeArguments, [ + node.arguments[0], + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([ + ..._createFakeTimersHookStatements(ctx), + ...describeBlock.statements, + ]), + ), + ...node.arguments.slice(2), + ]); +} + +function _transformFakeAsyncCall( + node: ts.CallExpression, + ctx: RefactorContext, + currentOutermostDescribeContext: CurrentOutermostDescribeContext, +): ts.CallExpression | ts.ArrowFunction { + currentOutermostDescribeContext.isUsingFakeAsync = true; + + const fakeAsyncCallback = node.arguments[0]; + if (!_is.arrowOrFunction(fakeAsyncCallback)) { + return node; + } + const callbackBody = ts.isBlock(fakeAsyncCallback.body) + ? fakeAsyncCallback.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(fakeAsyncCallback.body)]); + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`fakeAsync\` to \`vi.useFakeTimers\`.`, + ); + + return ts.factory.createArrowFunction( + [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock(callbackBody.statements), + ); +} + +function _createFakeTimersHookStatements(ctx: RefactorContext): ts.Statement[] { + return [ + // > beforeAll(() => { + // > vi.useFakeTimers({ + // > advanceTimeDelta: 1, + // > shouldAdvanceTime: true + // > }); + // > }); + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('beforeAll'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock( + [ + ts.factory.createExpressionStatement( + createViCallExpression(ctx, 'useFakeTimers', [ + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'advanceTimeDelta', + ts.factory.createNumericLiteral(1), + ), + ts.factory.createPropertyAssignment( + 'shouldAdvanceTime', + ts.factory.createTrue(), + ), + ]), + ]), + ), + ], + true, + ), + ), + ]), + ), + + // > afterAll(() => { + // > vi.useRealTimers({ + // > advanceTimeDelta: 1, + // > shouldAdvanceTime: true + // > }); + // > }); + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('afterAll'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock( + [ts.factory.createExpressionStatement(createViCallExpression(ctx, 'useRealTimers'))], + true, + ), + ), + ]), + ), + ]; +} + +interface CurrentOutermostDescribeContext { + isUsingFakeAsync: boolean; +} + +function _findDescribeBlock(node: ts.CallExpression): ts.Block | undefined { + const args = node.arguments; + const describeCallback = args.length >= 2 ? args[1] : undefined; + if (describeCallback !== undefined && _is.arrowOrFunction(describeCallback)) { + return _getFunctionBlock(describeCallback); + } + + return undefined; +} + +function _getFunctionBlock(node: ts.FunctionExpression | ts.ArrowFunction): ts.Block { + return ts.isBlock(node.body) + ? node.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(node.body)]); +} + +const _is = { + arrowOrFunction: (node: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression => + ts.isArrowFunction(node) || ts.isFunctionExpression(node), + describe: (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + // describe + ((ts.isIdentifier(node.expression) && node.expression.text === 'describe') || + // describe.only or describe.skip + (ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'describe')), +}; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts new file mode 100644 index 000000000000..1e3d87a425cb --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncTest', () => { + const testCases = [ + { + description: 'should transform fakeAsync test to `vi.useFakeTimers()`', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async () => { + expect(1).toBe(1); + }); + }); + `, + }, + { + description: 'should transform fakeAsync test to `vi.useFakeTimers()` in outer describe', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe('My outer fakeAsync suite', () => { + + describe('My inner fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + + }); + + `, + expected: ` + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe('My outer fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + describe('My inner fakeAsync suite', () => { + it('works', async () => { + expect(1).toBe(1); + }); + }); + }); + `, + }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` in outer describe even if it is excluded', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + xdescribe('My outer fakeAsync suite', () => { + + describe('My inner fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + + }); + + `, + expected: ` + describe('My non-fakeAsync suite', () => { + it('works', () => { + expect(1).toBe(1); + }); + }); + + describe.skip('My outer fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + + describe('My inner fakeAsync suite', () => { + it('works', async () => { + expect(1).toBe(1); + }); + }); + }); + `, + }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` in `beforeEach`, `afterEach`, `beforeAll`, `afterAll`', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + beforeAll(fakeAsync(() => { + console.log('beforeAll'); + })); + + afterAll(fakeAsync(() => { + console.log('afterAll'); + })); + + beforeEach(fakeAsync(() => { + console.log('beforeEach'); + })); + + afterEach(fakeAsync(() => { + console.log('afterEach'); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + beforeAll(async () => { + console.log('beforeAll'); + }); + + afterAll(async () => { + console.log('afterAll'); + }); + + beforeEach(async () => { + console.log('beforeEach'); + }); + + afterEach(async () => { + console.log('afterEach'); + }); + }); + `, + }, + { + description: 'should not replace `fakeAsync` if not used within a describe block', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + expected: ` + import { fakeAsync } from '@angular/core/testing'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + }, + { + description: 'should not replace `fakeAsync` if not imported from `@angular/core/testing`', + input: ` + import { fakeAsync } from './my-fake-async'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + import { fakeAsync } from './my-fake-async'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + }); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts new file mode 100644 index 000000000000..3d5a95bf0209 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; + +export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'tick' && + isNamedImportFrom(ctx.sourceFile, 'tick', ANGULAR_CORE_TESTING) + ) + ) { + return node; + } + + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, + ); + + addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); + + const durationNumericLiteral = + node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) + ? node.arguments[0] + : ts.factory.createNumericLiteral(0); + + return ts.factory.createAwaitExpression( + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [durationNumericLiteral]), + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts new file mode 100644 index 000000000000..bd60fad783c5 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { expectTransformation } from '../test-helpers'; + +describe('transformFakeAsyncTick', () => { + const testCases = [ + { + description: 'should replace `tick` with `vi.advanceTimersByTimeAsync`', + input: ` + import { tick } from '@angular/core/testing'; + + tick(100); + `, + expected: `await vi.advanceTimersByTimeAsync(100);`, + }, + { + description: 'should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', + input: ` + import { tick } from '@angular/core/testing'; + + tick(); + `, + expected: `await vi.advanceTimersByTimeAsync(0);`, + }, + { + description: 'should not replace `tick` if not imported from `@angular/core/testing`', + input: ` + import { tick } from './my-tick'; + + tick(100); + `, + expected: ` + import { tick } from './my-tick'; + + tick(100); + `, + }, + ]; + + 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 243eea1b2878..6832e36b9273 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -14,16 +14,15 @@ */ import ts from 'typescript'; -import { addVitestValueImport, createViCallExpression } from '../utils/ast-helpers'; +import { addVitestValueImport } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; import { addTodoComment } from '../utils/comment-helpers'; import { RefactorContext } from '../utils/refactor-context'; +import { createViCallExpression } from '../utils/refactor-helpers'; import { TodoCategory } from '../utils/todo-notes'; -export function transformTimerMocks( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformTimerMocks(node: ts.Node, ctx: RefactorContext): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; if ( !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || @@ -85,7 +84,7 @@ export function transformTimerMocks( ]; } - return createViCallExpression(newMethodName, newArgs); + return createViCallExpression(ctx, newMethodName, newArgs); } return node; @@ -173,15 +172,16 @@ export function transformJasmineMembers(node: ts.Node, refactorCtx: RefactorCont function transformJasmineDefaultTimeoutInterval( expression: ts.ExpressionStatement, timeoutValue: ts.Expression, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, + ctx: RefactorContext, ): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; addVitestValueImport(pendingVitestValueImports, 'vi'); reporter.reportTransformation( sourceFile, expression, 'Transformed `jasmine.DEFAULT_TIMEOUT_INTERVAL` to `vi.setConfig()`.', ); - const setConfigCall = createViCallExpression('setConfig', [ + const setConfigCall = createViCallExpression(ctx, 'setConfig', [ ts.factory.createObjectLiteralExpression( [ts.factory.createPropertyAssignment('testTimeout', timeoutValue)], 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 2c9b6f8cc686..4797eacd22b2 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -17,12 +17,12 @@ import ts from 'typescript'; import { addVitestValueImport, createPropertyAccess, - createViCallExpression, getPromiseResolveRejectMethod, } from '../utils/ast-helpers'; import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation'; 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; @@ -221,8 +221,9 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. export function transformCreateSpy( node: ts.Node, - { reporter, sourceFile, pendingVitestValueImports }: RefactorContext, + ctx: RefactorContext, ): ts.Node { + const { reporter, sourceFile, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpy')) { return node; } @@ -236,6 +237,7 @@ export function transformCreateSpy( const spyName = node.arguments[0]; const viFnCallExpression = createViCallExpression( + ctx, 'fn', node.arguments.length > 1 ? [node.arguments[1]] : [], ); @@ -253,8 +255,9 @@ export function transformCreateSpy( export function transformCreateSpyObj( node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, + ctx: RefactorContext, ): ts.Node { + const { reporter, sourceFile, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpyObj')) { return node; } @@ -282,9 +285,9 @@ export function transformCreateSpyObj( } if (ts.isArrayLiteralExpression(methods)) { - properties = createSpyObjWithArray(methods, baseName); + properties = createSpyObjWithArray(ctx, methods, baseName); } else if (ts.isObjectLiteralExpression(methods)) { - properties = createSpyObjWithObject(methods, baseName); + properties = createSpyObjWithObject(ctx, methods, baseName); } else { const category = 'createSpyObj-dynamic-variable'; reporter.recordTodo(category, sourceFile, node); @@ -307,13 +310,14 @@ export function transformCreateSpyObj( } function createSpyObjWithArray( + ctx: RefactorContext, methods: ts.ArrayLiteralExpression, baseName: string | undefined, ): ts.PropertyAssignment[] { return methods.elements .map((element) => { if (ts.isStringLiteral(element)) { - const mockFn = createViCallExpression('fn'); + const mockFn = createViCallExpression(ctx, 'fn'); const methodName = element.text; let finalExpression: ts.Expression = mockFn; @@ -337,6 +341,7 @@ function createSpyObjWithArray( } function createSpyObjWithObject( + ctx: RefactorContext, methods: ts.ObjectLiteralExpression, baseName: string | undefined, ): ts.PropertyAssignment[] { @@ -345,7 +350,7 @@ function createSpyObjWithObject( if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { const methodName = prop.name.text; const returnValue = prop.initializer; - let mockFn = createViCallExpression('fn'); + let mockFn = createViCallExpression(ctx, 'fn'); if (baseName) { mockFn = ts.factory.createCallExpression( 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 8cbf089d05a8..19326338e831 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -48,32 +48,17 @@ export function getVitestAutoImports( allSpecifiers.sort((a, b) => a.name.text.localeCompare(b.name.text)); - const importClause = ts.factory.createImportClause( - isClauseTypeOnly, // Set isTypeOnly on the clause if only type imports - undefined, - ts.factory.createNamedImports(allSpecifiers), - ); - return ts.factory.createImportDeclaration( undefined, - importClause, + ts.factory.createImportClause( + isClauseTypeOnly, // Set isTypeOnly on the clause if only type imports + undefined, + ts.factory.createNamedImports(allSpecifiers), + ), ts.factory.createStringLiteral('vitest'), ); } -export function createViCallExpression( - methodName: string, - args: readonly ts.Expression[] = [], - typeArgs: ts.TypeNode[] | undefined = undefined, -): ts.CallExpression { - const callee = ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - methodName, - ); - - return ts.factory.createCallExpression(callee, typeArgs, args); -} - export function createExpectCallExpression( args: ts.Expression[], typeArgs: ts.TypeNode[] | undefined = undefined, @@ -121,3 +106,102 @@ export function getPromiseResolveRejectMethod(node: ts.Node): { arguments: node.arguments, }; } + +/** + * Checks if a named binding is imported from the given module in the source file. + * @param sourceFile The source file to search for imports. + * @param name The import name (e.g. 'flush', 'tick'). + * @param moduleSpecifier The module path (e.g. '@angular/core/testing'). + */ +export function isNamedImportFrom( + sourceFile: ts.SourceFile, + name: string, + moduleSpecifier: string, +): boolean { + return sourceFile.statements.some((statement) => { + if (!_isImportDeclarationWithNamedBindings(statement)) { + return false; + } + + const specifier = statement.moduleSpecifier; + const modulePath = ts.isStringLiteralLike(specifier) ? specifier.text : null; + if (modulePath !== moduleSpecifier) { + return false; + } + for (const element of statement.importClause.namedBindings.elements) { + const importedName = element.propertyName ? element.propertyName.text : element.name.text; + if (importedName === name) { + return true; + } + } + }); +} + +/** + * Removes specified import specifiers from ImportDeclarations. + * If all specifiers are removed from an import, the entire import is dropped. + */ +export function removeImportSpecifiers( + sourceFile: ts.SourceFile, + removals: Map>, +): ts.SourceFile { + const newStatements = sourceFile.statements + .map((statement) => { + if (!_isImportDeclarationWithNamedBindings(statement)) { + return statement; + } + + const specifier = statement.moduleSpecifier; + const modulePath = ts.isStringLiteralLike(specifier) ? specifier.text : null; + if (modulePath === null) { + return statement; + } + + const namesToRemove = removals.get(modulePath); + if (namesToRemove === undefined || namesToRemove.size === 0) { + return statement; + } + + const remaining = statement.importClause.namedBindings.elements.filter((el) => { + const name = el.propertyName ? el.propertyName.text : el.name.text; + + return !namesToRemove.has(name); + }); + + if (remaining.length === 0) { + return; + } + + if (remaining.length === statement.importClause.namedBindings.elements.length) { + return statement; + } + + return ts.factory.updateImportDeclaration( + statement, + statement.modifiers, + ts.factory.updateImportClause( + statement.importClause, + undefined, + statement.importClause.name, + ts.factory.createNamedImports(remaining), + ), + statement.moduleSpecifier, + statement.attributes, + ); + }) + .filter((statement) => statement !== undefined); + + return ts.factory.updateSourceFile(sourceFile, newStatements); +} + +function _isImportDeclarationWithNamedBindings( + statement: ts.Statement, +): statement is ts.ImportDeclaration & { + importClause: ts.ImportClause & { namedBindings: ts.NamedImports }; +} { + return ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings !== undefined && + ts.isNamedImports(statement.importClause.namedBindings) + ); +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts new file mode 100644 index 000000000000..23407fee97da --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export const ANGULAR_CORE_TESTING = '@angular/core/testing'; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts index d2599ed16ed7..6aa7052685d3 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -28,6 +28,12 @@ export interface RefactorContext { /** A set of Vitest type imports to be added to the file. */ readonly pendingVitestTypeImports: Set; + + /** + * Map of module specifier -> names to remove from that import. + * Used when transforming identifiers that become inlined (e.g. flush -> vi.runAllTimersAsync). + */ + readonly pendingImportSpecifierRemovals: Map>; } /** diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts new file mode 100644 index 000000000000..616c1bae6114 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { RefactorContext } from './refactor-context'; + +/** + * Marks an identifier to be removed from an import specifier. + * + * @param ctx The refactor context object. + * @param name The name of the identifier to remove from the import specifier. + * @param moduleSpecifier The module specifier to remove the identifier from. + */ +export function addImportSpecifierRemoval( + ctx: RefactorContext, + name: string, + moduleSpecifier: string, +): void { + const removals = ctx.pendingImportSpecifierRemovals.get(moduleSpecifier) ?? new Set(); + removals.add(name); + ctx.pendingImportSpecifierRemovals.set(moduleSpecifier, removals); +} + +/** + * Creates a call expression to a vitest method. + * This also adds the `vi` identifier to the context object, + * to import it later if addImports option is enabled. + * + * @param ctx The refactor context object. + * @param args The arguments to pass to the method. + * @param typeArgs The type arguments to pass to the method. + * @param methodeName The name of the vitest method to call. + * @returns The created identifier node. + */ +export function createViCallExpression( + ctx: RefactorContext, + methodName: string, + args: readonly ts.Expression[] = [], + typeArgs: ts.TypeNode[] | undefined = undefined, +): ts.CallExpression { + const vi = requireVitestIdentifier(ctx, 'vi'); + const callee = ts.factory.createPropertyAccessExpression(vi, methodName); + + return ts.factory.createCallExpression(callee, typeArgs, args); +} + +/** + * Creates an identifier for a vitest value import. + * This also adds the identifier to the context object, + * to import it later if addImports option is enabled. + * + * @param ctx The refactor context object. + * @param name The name of the vitest identifier to require. + * @returns The created identifier node. + */ +export function requireVitestIdentifier(ctx: RefactorContext, name: string): ts.Identifier { + ctx.pendingVitestValueImports.add(name); + + return ts.factory.createIdentifier(name); +} 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 2a3f155a9393..598606d7bde6 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -178,6 +178,13 @@ export const TODO_NOTES = { 'unhandled-done-usage': { message: "The 'done' callback was used in an unhandled way. Please migrate manually.", }, + 'flush-max-turns': { + message: + 'flush(maxTurns) was called but maxTurns parameter is not migrated. Please migrate manually.', + }, + 'flush-return-value': { + message: 'flush() return value is not migrated. Please migrate manually.', + }, } as const; /** From ed56b64f1b6a377bdba3bbf82973095941e2237e Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Fri, 1 May 2026 21:19:20 +0200 Subject: [PATCH 2/5] fixup! feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../jasmine-vitest/transformers/fake-async-tick.ts | 4 +--- .../transformers/fake-async-tick_spec.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts index 3d5a95bf0209..91932bad957e 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts @@ -33,9 +33,7 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); const durationNumericLiteral = - node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) - ? node.arguments[0] - : ts.factory.createNumericLiteral(0); + node.arguments.length > 0 ? node.arguments[0] : ts.factory.createNumericLiteral(0); return ts.factory.createAwaitExpression( createViCallExpression(ctx, 'advanceTimersByTimeAsync', [durationNumericLiteral]), diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts index bd60fad783c5..b8c3b947a160 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts @@ -19,6 +19,20 @@ describe('transformFakeAsyncTick', () => { `, expected: `await vi.advanceTimersByTimeAsync(100);`, }, + { + description: + 'should replace `tick` with `vi.advanceTimersByTimeAsync` even if it using a non-literal argument', + input: ` + import { tick } from '@angular/core/testing'; + + const duration = 100; + tick(duration); + `, + expected: ` + const duration = 100; + await vi.advanceTimersByTimeAsync(duration); + `, + }, { description: 'should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', input: ` From f2270b532b823edd90c2647d6daf3301977cbab4 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Fri, 1 May 2026 21:27:38 +0200 Subject: [PATCH 3/5] fixup! feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../refactor/jasmine-vitest/transformers/fake-async-test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts index ba2161687eb6..41fca3d24ee0 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts @@ -157,10 +157,7 @@ function _createFakeTimersHookStatements(ctx: RefactorContext): ts.Statement[] { ), // > afterAll(() => { - // > vi.useRealTimers({ - // > advanceTimeDelta: 1, - // > shouldAdvanceTime: true - // > }); + // > vi.useRealTimers(); // > }); ts.factory.createExpressionStatement( ts.factory.createCallExpression(ts.factory.createIdentifier('afterAll'), undefined, [ From d417fa0ab14b9e42f27689cd129bfd4d1bbe22ac Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Fri, 1 May 2026 21:54:44 +0200 Subject: [PATCH 4/5] fixup! feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../transformers/fake-async-test.ts | 4 +-- .../transformers/fake-async-test_spec.ts | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts index 41fca3d24ee0..ea2a2ef52cb5 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts @@ -109,8 +109,8 @@ function _transformFakeAsyncCall( return ts.factory.createArrowFunction( [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], - undefined, - [], + fakeAsyncCallback.typeParameters, + fakeAsyncCallback.parameters, undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), ts.factory.createBlock(callbackBody.statements), diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts index 1e3d87a425cb..04bbd15f8a32 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts @@ -35,6 +35,32 @@ describe('transformFakeAsyncTest', () => { }); `, }, + { + description: + 'should transform fakeAsync test to `vi.useFakeTimers()` and keep its arguments but not the return type', + input: ` + import { fakeAsync } from '@angular/core/testing'; + + describe('My fakeAsync suite', () => { + it('works', fakeAsync((strangeArg: Strange): void => { + expect(1).toBe(1); + })); + }); + `, + expected: ` + describe('My fakeAsync suite', () => { + beforeAll(() => { + vi.useFakeTimers({ advanceTimeDelta: 1, shouldAdvanceTime: true }); + }); + afterAll(() => { + vi.useRealTimers(); + }); + it('works', async (strangeArg: Strange) => { + expect(1).toBe(1); + }); + }); + `, + }, { description: 'should transform fakeAsync test to `vi.useFakeTimers()` in outer describe', input: ` From 1ed014b230d8b592d9519b8f7f84f3ad34a0701d Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Fri, 1 May 2026 22:04:38 +0200 Subject: [PATCH 5/5] fixup! feat(@schematics/angular): migrate fake async to Vitest fake timers --- .../jasmine-vitest/transformers/fake-async-test_spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts index 04bbd15f8a32..5efb23282f5a 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts @@ -42,7 +42,7 @@ describe('transformFakeAsyncTest', () => { import { fakeAsync } from '@angular/core/testing'; describe('My fakeAsync suite', () => { - it('works', fakeAsync((strangeArg: Strange): void => { + it('works', fakeAsync((strangeArg: Strange = myStrangeDefault): void => { expect(1).toBe(1); })); }); @@ -55,7 +55,7 @@ describe('transformFakeAsyncTest', () => { afterAll(() => { vi.useRealTimers(); }); - it('works', async (strangeArg: Strange) => { + it('works', async (strangeArg: Strange = myStrangeDefault) => { expect(1).toBe(1); }); });