From 8beba0c2baf10fb3af7f812eac2c64cabdfc7bfb Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Tue, 17 Mar 2026 14:21:39 +0100 Subject: [PATCH 01/16] feat(@schematics/angular): add `refactor-fake-async` migration --- packages/schematics/angular/collection.json | 6 + .../angular/refactor/fake-async/index.ts | 43 +++++++ .../angular/refactor/fake-async/index_spec.ts | 112 ++++++++++++++++++ .../refactor/fake-async/refactor-context.ts | 17 +++ .../angular/refactor/fake-async/schema.json | 15 +++ .../fake-async/transform-fake-async.ts | 79 ++++++++++++ .../refactor/fake-async/transformers/flush.ts | 22 ++++ .../transformers/setup-fake-timers.ts | 73 ++++++++++++ .../refactor/fake-async/transformers/tick.ts | 28 +++++ .../angular/refactor/jasmine-vitest/index.ts | 26 +--- .../jasmine-vitest/utils/ast-helpers.ts | 12 +- .../angular/utility/find-test-files.ts | 26 ++++ 12 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 packages/schematics/angular/refactor/fake-async/index.ts create mode 100644 packages/schematics/angular/refactor/fake-async/index_spec.ts create mode 100644 packages/schematics/angular/refactor/fake-async/refactor-context.ts create mode 100644 packages/schematics/angular/refactor/fake-async/schema.json create mode 100644 packages/schematics/angular/refactor/fake-async/transform-fake-async.ts create mode 100644 packages/schematics/angular/refactor/fake-async/transformers/flush.ts create mode 100644 packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts create mode 100644 packages/schematics/angular/refactor/fake-async/transformers/tick.ts create mode 100644 packages/schematics/angular/utility/find-test-files.ts diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 29b361ccafbb..89724322d59b 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -138,6 +138,12 @@ "private": true, "description": "[INTERNAL] Adds tailwind to a project. Intended for use for ng new/add." }, + "refactor-fake-async": { + "factory": "./refactor/fake-async", + "schema": "./refactor/fake-async/schema.json", + "description": "[EXPERIMENTAL] Refactors Angular fakeAsync to Vitest fake timers.", + "hidden": true + }, "refactor-jasmine-vitest": { "factory": "./refactor/jasmine-vitest", "schema": "./refactor/jasmine-vitest/schema.json", diff --git a/packages/schematics/angular/refactor/fake-async/index.ts b/packages/schematics/angular/refactor/fake-async/index.ts new file mode 100644 index 000000000000..d5b84ce2410a --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/index.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 { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { findTestFiles } from '../../utility/find-test-files'; +import { getWorkspace } from '../../utility/workspace'; +import { Schema as FakeAsyncOptions } from './schema'; +import { transformFakeAsync } from './transform-fake-async'; + +export default function (options: FakeAsyncOptions): Rule { + return async (tree: Tree, _context: SchematicContext) => { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(options.project); + + if (!project) { + throw new SchematicsException(`Project "${options.project}" does not exist.`); + } + + const projectRoot = project.root; + const fileSuffix = '.spec.ts'; + const files = findTestFiles(tree.getDir(projectRoot), fileSuffix); + + if (files.length === 0) { + throw new SchematicsException( + `No files ending with '${fileSuffix}' found in ${projectRoot}.`, + ); + } + + for (const file of files) { + const content = tree.readText(file); + const newContent = transformFakeAsync(file, content); + + if (content !== newContent) { + tree.overwrite(file, newContent); + } + } + }; +} diff --git a/packages/schematics/angular/refactor/fake-async/index_spec.ts b/packages/schematics/angular/refactor/fake-async/index_spec.ts new file mode 100644 index 000000000000..7fc1676346c6 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/index_spec.ts @@ -0,0 +1,112 @@ +/** + * @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 { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { format } from 'prettier'; +import { Schema as ApplicationOptions } from '../../application/schema'; +import { Schema as WorkspaceOptions } from '../../workspace/schema'; + +describe('Angular `fakeAsync` to Vitest Fake Timers Schematic', () => { + it("should replace `fakeAsync` with an async test using Vitest's fake timers", async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", fakeAsync(() => { + expect(1).toBe(1); +})); +`); + + expect(newContent).toBe(`\ +import { onTestFinished, vi } from "vitest"; +it("should work", async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + expect(1).toBe(1); +}); +`); + }); + + it('should replace `tick` with `vi.advanceTimersByTimeAsync`', async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", fakeAsync(() => { + tick(100); +})); +`); + + expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(100);`); + }); + + it('should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", fakeAsync(() => { + tick(); +})); +`); + + expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(0);`); + }); + + it('should replace `flush` with `vi.advanceTimersByTimeAsync`', async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", fakeAsync(() => { + flush(); +})); +`); + + expect(newContent).toContain(`await vi.runAllTimersAsync();`); + }); +}); + +async function setUp() { + const schematicRunner = new SchematicTestRunner( + '@schematics/angular', + require.resolve('../../collection.json'), + ); + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '21.0.0', + }; + + const appOptions: ApplicationOptions = { + name: 'bar', + inlineStyle: false, + inlineTemplate: false, + routing: false, + skipTests: false, + skipPackageJson: false, + }; + + let appTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + appTree = await schematicRunner.runSchematic('application', appOptions, appTree); + + return { + transformContent: async (content: string): Promise => { + const specFilePath = 'projects/bar/src/app/app.spec.ts'; + + appTree.overwrite(specFilePath, content); + + const tree = await schematicRunner.runSchematic( + 'refactor-fake-async', + { project: 'bar' }, + appTree, + ); + + return format(tree.readContent(specFilePath), { parser: 'typescript' }); + }, + }; +} diff --git a/packages/schematics/angular/refactor/fake-async/refactor-context.ts b/packages/schematics/angular/refactor/fake-async/refactor-context.ts new file mode 100644 index 000000000000..d6ac08e78a3c --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/refactor-context.ts @@ -0,0 +1,17 @@ +/** + * @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 interface RefactorContext { + importedVitestIdentifiers: Set; +} + +export function createRefactorContext(): RefactorContext { + return { + importedVitestIdentifiers: new Set(), + }; +} diff --git a/packages/schematics/angular/refactor/fake-async/schema.json b/packages/schematics/angular/refactor/fake-async/schema.json new file mode 100644 index 000000000000..abc32a4e5441 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Angular `fakeAsync` to Vitest's Fake Timers", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + } + }, + "required": ["project"] +} diff --git a/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts b/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts new file mode 100644 index 000000000000..479f7083d229 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts @@ -0,0 +1,79 @@ +/** + * @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 '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { createRefactorContext } from './refactor-context'; +import { transformFlush } from './transformers/flush'; +import { transformSetupFakeTimers } from './transformers/setup-fake-timers'; +import { transformTick } from './transformers/tick'; + +/** + * Transforms Angular's `fakeAsync` to Vitest's fake timers. + * Replaces `it('...', fakeAsync(() => { ... }))` with an async test that uses + * `vi.useFakeTimers()` and `onTestFinished` for cleanup. + */ +export function transformFakeAsync(filePath: string, content: string): string { + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + const refactorContext = createRefactorContext(); + + const transformer: ts.TransformerFactory = (context) => { + const visit = (node: ts.Node): ts.Node => { + if (ts.isCallExpression(node)) { + for (const transformer of callExpressionTransformers) { + node = transformer(node, refactorContext); + } + } + + return ts.visitEachChild(node, visit, context); + }; + + return (sf) => ts.visitNode(sf, visit, ts.isSourceFile); + }; + + const result = ts.transform(sourceFile, [transformer]); + let transformedSourceFile = result.transformed[0]; + + if (refactorContext.importedVitestIdentifiers.size > 0) { + const indentifiers = Array.from(refactorContext.importedVitestIdentifiers); + const vitestImport = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + undefined, + undefined, + ts.factory.createNamedImports( + indentifiers.map((identifier) => + ts.factory.createImportSpecifier( + false, + undefined, + ts.factory.createIdentifier(identifier), + ), + ), + ), + ), + ts.factory.createStringLiteral('vitest', true), + ); + + transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [ + vitestImport, + ...transformedSourceFile.statements, + ]); + } + + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + + return printer.printFile(transformedSourceFile); +} + +const callExpressionTransformers = [transformSetupFakeTimers, transformTick, transformFlush]; diff --git a/packages/schematics/angular/refactor/fake-async/transformers/flush.ts b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts new file mode 100644 index 000000000000..f83f186fdc14 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts @@ -0,0 +1,22 @@ +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; + +export function transformFlush(node: ts.Node): ts.Node { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flush' + ) { + return ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'runAllTimersAsync', + ), + undefined, + [], + ), + ); + } + + return node; +} diff --git a/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts new file mode 100644 index 000000000000..05a87652e728 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts @@ -0,0 +1,73 @@ +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../refactor-context'; + +export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): ts.Node { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + node.arguments.length >= 1 && + (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) + ) { + ctx.importedVitestIdentifiers.add('onTestFinished'); + ctx.importedVitestIdentifiers.add('vi'); + + const callback = node.arguments[0]; + const callbackBody = ts.isBlock(callback.body) + ? callback.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]); + + // Generate the following code: + // vi.useFakeTimers(); + // onTestFinished(() => { + // vi.useRealTimers(); + // }); + const setupStatements: ts.Statement[] = [ + ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'useFakeTimers', + ), + undefined, + [], + ), + ), + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('onTestFinished'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([ + ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'useRealTimers', + ), + undefined, + [], + ), + ), + ]), + ), + ]), + ), + ...callbackBody.statements, + ]; + + return ts.factory.createArrowFunction( + [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock(setupStatements), + ); + } + + return node; +} diff --git a/packages/schematics/angular/refactor/fake-async/transformers/tick.ts b/packages/schematics/angular/refactor/fake-async/transformers/tick.ts new file mode 100644 index 000000000000..3dd785f4e695 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/tick.ts @@ -0,0 +1,28 @@ +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../refactor-context'; + +export function transformTick(node: ts.Node): ts.Node { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'tick' + ) { + const durationNumericLiteral = + node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) + ? node.arguments[0] + : ts.factory.createNumericLiteral(0); + + return ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'advanceTimersByTimeAsync', + ), + undefined, + [durationNumericLiteral], + ), + ); + } + + return node; +} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index 4ae4077a7be4..e9385fcc4521 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -18,6 +18,7 @@ import { ProjectDefinition, getWorkspace } from '../../utility/workspace'; import { Schema } from './schema'; import { transformJasmineToVitest } from './test-file-transformer'; import { RefactorReporter } from './utils/refactor-reporter'; +import { findTestFiles } from '../../utility/find-test-files'; async function getProject( tree: Tree, @@ -46,31 +47,6 @@ async function getProject( ); } -const DIRECTORIES_TO_SKIP = new Set(['node_modules', '.git', 'dist', '.angular']); - -function findTestFiles(directory: DirEntry, fileSuffix: string): string[] { - const files: string[] = []; - const stack: DirEntry[] = [directory]; - - let current: DirEntry | undefined; - while ((current = stack.pop())) { - for (const path of current.subfiles) { - if (path.endsWith(fileSuffix)) { - files.push(current.path + '/' + path); - } - } - - for (const path of current.subdirs) { - if (DIRECTORIES_TO_SKIP.has(path)) { - continue; - } - stack.push(current.dir(path)); - } - } - - return files; -} - export default function (options: Schema): Rule { return async (tree: Tree, context: SchematicContext) => { const reporter = new RefactorReporter(context.logger); 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 f6f363df1643..4119d3e0c2c7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -48,15 +48,13 @@ 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'), ); } diff --git a/packages/schematics/angular/utility/find-test-files.ts b/packages/schematics/angular/utility/find-test-files.ts new file mode 100644 index 000000000000..dc6ccb71a91a --- /dev/null +++ b/packages/schematics/angular/utility/find-test-files.ts @@ -0,0 +1,26 @@ +import { DirEntry } from '@angular-devkit/schematics'; + +const DIRECTORIES_TO_SKIP = new Set(['node_modules', '.git', 'dist', '.angular']); + +export function findTestFiles(directory: DirEntry, fileSuffix: string): string[] { + const files: string[] = []; + const stack: DirEntry[] = [directory]; + + let current: DirEntry | undefined; + while ((current = stack.pop())) { + for (const path of current.subfiles) { + if (path.endsWith(fileSuffix)) { + files.push(current.path + '/' + path); + } + } + + for (const path of current.subdirs) { + if (DIRECTORIES_TO_SKIP.has(path)) { + continue; + } + stack.push(current.dir(path)); + } + } + + return files; +} From 191de45a6bf84bc623f97dfeb792751312b7ac85 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Tue, 17 Mar 2026 17:29:22 +0100 Subject: [PATCH 02/16] feat(@schematics/angular): do not transform flush & tick if used outside fakeAsync --- .../angular/refactor/fake-async/index_spec.ts | 24 ++++++++++++ .../refactor/fake-async/refactor-context.ts | 2 + .../fake-async/transform-fake-async.ts | 11 +++++- .../refactor/fake-async/transformers/flush.ts | 12 +++++- .../transformers/setup-fake-timers.ts | 38 +++++++++++++------ .../refactor/fake-async/transformers/tick.ts | 11 +++++- 6 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/schematics/angular/refactor/fake-async/index_spec.ts b/packages/schematics/angular/refactor/fake-async/index_spec.ts index 7fc1676346c6..24d433b446e9 100644 --- a/packages/schematics/angular/refactor/fake-async/index_spec.ts +++ b/packages/schematics/angular/refactor/fake-async/index_spec.ts @@ -68,6 +68,30 @@ it("should work", fakeAsync(() => { expect(newContent).toContain(`await vi.runAllTimersAsync();`); }); + + it('should not transform `tick` if not inside `fakeAsync`', async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", () => { + tick(100); +})); +`); + + expect(newContent).toContain(`tick(100);`); + }); + + it('should not transform `flush` if not inside `fakeAsync`', async () => { + const { transformContent } = await setUp(); + + const newContent = await transformContent(` +it("should work", () => { + flush(); +})); +`); + + expect(newContent).toContain(`flush();`); + }); }); async function setUp() { diff --git a/packages/schematics/angular/refactor/fake-async/refactor-context.ts b/packages/schematics/angular/refactor/fake-async/refactor-context.ts index d6ac08e78a3c..5f43df449e21 100644 --- a/packages/schematics/angular/refactor/fake-async/refactor-context.ts +++ b/packages/schematics/angular/refactor/fake-async/refactor-context.ts @@ -8,10 +8,12 @@ export interface RefactorContext { importedVitestIdentifiers: Set; + isInsideFakeAsync: boolean; } export function createRefactorContext(): RefactorContext { return { importedVitestIdentifiers: new Set(), + isInsideFakeAsync: false, }; } diff --git a/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts b/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts index 479f7083d229..caaab73ccd9d 100644 --- a/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts +++ b/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts @@ -9,7 +9,10 @@ import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { createRefactorContext } from './refactor-context'; import { transformFlush } from './transformers/flush'; -import { transformSetupFakeTimers } from './transformers/setup-fake-timers'; +import { + isFakeAsyncCallExpression, + transformSetupFakeTimers, +} from './transformers/setup-fake-timers'; import { transformTick } from './transformers/tick'; /** @@ -31,9 +34,15 @@ export function transformFakeAsync(filePath: string, content: string): string { const transformer: ts.TransformerFactory = (context) => { const visit = (node: ts.Node): ts.Node => { if (ts.isCallExpression(node)) { + const isFakeAsync = isFakeAsyncCallExpression(node); + for (const transformer of callExpressionTransformers) { node = transformer(node, refactorContext); } + + if (isFakeAsync) { + refactorContext.isInsideFakeAsync = true; + } } return ts.visitEachChild(node, visit, context); diff --git a/packages/schematics/angular/refactor/fake-async/transformers/flush.ts b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts index f83f186fdc14..6d17a4f0c9f7 100644 --- a/packages/schematics/angular/refactor/fake-async/transformers/flush.ts +++ b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts @@ -1,7 +1,17 @@ +/** + * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../refactor-context'; -export function transformFlush(node: ts.Node): ts.Node { +export function transformFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( + ctx.isInsideFakeAsync && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'flush' diff --git a/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts index 05a87652e728..ea6e5ac5b8b8 100644 --- a/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts +++ b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts @@ -1,16 +1,19 @@ +/** + * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { RefactorContext } from '../refactor-context'; export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): ts.Node { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'fakeAsync' && - node.arguments.length >= 1 && - (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) - ) { + if (isFakeAsyncCallExpression(node)) { ctx.importedVitestIdentifiers.add('onTestFinished'); ctx.importedVitestIdentifiers.add('vi'); + ctx.isInsideFakeAsync = true; const callback = node.arguments[0]; const callbackBody = ts.isBlock(callback.body) @@ -18,10 +21,10 @@ export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): t : ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]); // Generate the following code: - // vi.useFakeTimers(); - // onTestFinished(() => { - // vi.useRealTimers(); - // }); + // > vi.useFakeTimers(); + // > onTestFinished(() => { + // > vi.useRealTimers(); + // > }); const setupStatements: ts.Statement[] = [ ts.factory.createExpressionStatement( ts.factory.createCallExpression( @@ -71,3 +74,16 @@ export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): t return node; } + +export function isFakeAsyncCallExpression(node: ts.Node): node is ts.CallExpression & { + expression: ts.Identifier; + arguments: [ts.ArrowFunction | ts.FunctionExpression]; +} { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + node.arguments.length >= 1 && + (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) + ); +} diff --git a/packages/schematics/angular/refactor/fake-async/transformers/tick.ts b/packages/schematics/angular/refactor/fake-async/transformers/tick.ts index 3dd785f4e695..2cd221463b62 100644 --- a/packages/schematics/angular/refactor/fake-async/transformers/tick.ts +++ b/packages/schematics/angular/refactor/fake-async/transformers/tick.ts @@ -1,8 +1,17 @@ +/** + * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { RefactorContext } from '../refactor-context'; -export function transformTick(node: ts.Node): ts.Node { +export function transformTick(node: ts.Node, ctx: RefactorContext): ts.Node { if ( + ctx.isInsideFakeAsync && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'tick' From 48da589d00d324a53e29f9f8b5c4361fe5584a70 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 10:45:36 +0100 Subject: [PATCH 03/16] refactor(@schematics/angular): move fakeAsync transforms to jamine-vitest migration --- packages/schematics/angular/collection.json | 6 - .../angular/refactor/fake-async/index.ts | 43 ------ .../angular/refactor/fake-async/index_spec.ts | 136 ------------------ .../refactor/fake-async/refactor-context.ts | 19 --- .../angular/refactor/fake-async/schema.json | 15 -- .../fake-async/transform-fake-async.ts | 88 ------------ .../transformers/setup-fake-timers.ts | 89 ------------ .../angular/refactor/jasmine-vitest/index.ts | 26 +++- .../jasmine-vitest/test-file-transformer.ts | 6 + .../test-file-transformer_add-imports_spec.ts | 20 +++ .../transformers/fake-async-flush.ts} | 4 +- .../transformers/fake-async-flush_spec.ts | 25 ++++ .../transformers/fake-async-test.ts | 88 ++++++++++++ .../transformers/fake-async-test_spec.ts | 37 +++++ .../transformers/fake-async-tick.ts} | 4 +- .../transformers/fake-async-tick_spec.ts | 30 ++++ .../angular/utility/find-test-files.ts | 26 ---- 17 files changed, 233 insertions(+), 429 deletions(-) delete mode 100644 packages/schematics/angular/refactor/fake-async/index.ts delete mode 100644 packages/schematics/angular/refactor/fake-async/index_spec.ts delete mode 100644 packages/schematics/angular/refactor/fake-async/refactor-context.ts delete mode 100644 packages/schematics/angular/refactor/fake-async/schema.json delete mode 100644 packages/schematics/angular/refactor/fake-async/transform-fake-async.ts delete mode 100644 packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts rename packages/schematics/angular/refactor/{fake-async/transformers/flush.ts => jasmine-vitest/transformers/fake-async-flush.ts} (81%) 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 rename packages/schematics/angular/refactor/{fake-async/transformers/tick.ts => jasmine-vitest/transformers/fake-async-tick.ts} (84%) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts delete mode 100644 packages/schematics/angular/utility/find-test-files.ts diff --git a/packages/schematics/angular/collection.json b/packages/schematics/angular/collection.json index 89724322d59b..29b361ccafbb 100755 --- a/packages/schematics/angular/collection.json +++ b/packages/schematics/angular/collection.json @@ -138,12 +138,6 @@ "private": true, "description": "[INTERNAL] Adds tailwind to a project. Intended for use for ng new/add." }, - "refactor-fake-async": { - "factory": "./refactor/fake-async", - "schema": "./refactor/fake-async/schema.json", - "description": "[EXPERIMENTAL] Refactors Angular fakeAsync to Vitest fake timers.", - "hidden": true - }, "refactor-jasmine-vitest": { "factory": "./refactor/jasmine-vitest", "schema": "./refactor/jasmine-vitest/schema.json", diff --git a/packages/schematics/angular/refactor/fake-async/index.ts b/packages/schematics/angular/refactor/fake-async/index.ts deleted file mode 100644 index d5b84ce2410a..000000000000 --- a/packages/schematics/angular/refactor/fake-async/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @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 { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; -import { findTestFiles } from '../../utility/find-test-files'; -import { getWorkspace } from '../../utility/workspace'; -import { Schema as FakeAsyncOptions } from './schema'; -import { transformFakeAsync } from './transform-fake-async'; - -export default function (options: FakeAsyncOptions): Rule { - return async (tree: Tree, _context: SchematicContext) => { - const workspace = await getWorkspace(tree); - const project = workspace.projects.get(options.project); - - if (!project) { - throw new SchematicsException(`Project "${options.project}" does not exist.`); - } - - const projectRoot = project.root; - const fileSuffix = '.spec.ts'; - const files = findTestFiles(tree.getDir(projectRoot), fileSuffix); - - if (files.length === 0) { - throw new SchematicsException( - `No files ending with '${fileSuffix}' found in ${projectRoot}.`, - ); - } - - for (const file of files) { - const content = tree.readText(file); - const newContent = transformFakeAsync(file, content); - - if (content !== newContent) { - tree.overwrite(file, newContent); - } - } - }; -} diff --git a/packages/schematics/angular/refactor/fake-async/index_spec.ts b/packages/schematics/angular/refactor/fake-async/index_spec.ts deleted file mode 100644 index 24d433b446e9..000000000000 --- a/packages/schematics/angular/refactor/fake-async/index_spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @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 { SchematicTestRunner } from '@angular-devkit/schematics/testing'; -import { format } from 'prettier'; -import { Schema as ApplicationOptions } from '../../application/schema'; -import { Schema as WorkspaceOptions } from '../../workspace/schema'; - -describe('Angular `fakeAsync` to Vitest Fake Timers Schematic', () => { - it("should replace `fakeAsync` with an async test using Vitest's fake timers", async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", fakeAsync(() => { - expect(1).toBe(1); -})); -`); - - expect(newContent).toBe(`\ -import { onTestFinished, vi } from "vitest"; -it("should work", async () => { - vi.useFakeTimers(); - onTestFinished(() => { - vi.useRealTimers(); - }); - expect(1).toBe(1); -}); -`); - }); - - it('should replace `tick` with `vi.advanceTimersByTimeAsync`', async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", fakeAsync(() => { - tick(100); -})); -`); - - expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(100);`); - }); - - it('should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", fakeAsync(() => { - tick(); -})); -`); - - expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(0);`); - }); - - it('should replace `flush` with `vi.advanceTimersByTimeAsync`', async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", fakeAsync(() => { - flush(); -})); -`); - - expect(newContent).toContain(`await vi.runAllTimersAsync();`); - }); - - it('should not transform `tick` if not inside `fakeAsync`', async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", () => { - tick(100); -})); -`); - - expect(newContent).toContain(`tick(100);`); - }); - - it('should not transform `flush` if not inside `fakeAsync`', async () => { - const { transformContent } = await setUp(); - - const newContent = await transformContent(` -it("should work", () => { - flush(); -})); -`); - - expect(newContent).toContain(`flush();`); - }); -}); - -async function setUp() { - const schematicRunner = new SchematicTestRunner( - '@schematics/angular', - require.resolve('../../collection.json'), - ); - - const workspaceOptions: WorkspaceOptions = { - name: 'workspace', - newProjectRoot: 'projects', - version: '21.0.0', - }; - - const appOptions: ApplicationOptions = { - name: 'bar', - inlineStyle: false, - inlineTemplate: false, - routing: false, - skipTests: false, - skipPackageJson: false, - }; - - let appTree = await schematicRunner.runSchematic('workspace', workspaceOptions); - appTree = await schematicRunner.runSchematic('application', appOptions, appTree); - - return { - transformContent: async (content: string): Promise => { - const specFilePath = 'projects/bar/src/app/app.spec.ts'; - - appTree.overwrite(specFilePath, content); - - const tree = await schematicRunner.runSchematic( - 'refactor-fake-async', - { project: 'bar' }, - appTree, - ); - - return format(tree.readContent(specFilePath), { parser: 'typescript' }); - }, - }; -} diff --git a/packages/schematics/angular/refactor/fake-async/refactor-context.ts b/packages/schematics/angular/refactor/fake-async/refactor-context.ts deleted file mode 100644 index 5f43df449e21..000000000000 --- a/packages/schematics/angular/refactor/fake-async/refactor-context.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @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 interface RefactorContext { - importedVitestIdentifiers: Set; - isInsideFakeAsync: boolean; -} - -export function createRefactorContext(): RefactorContext { - return { - importedVitestIdentifiers: new Set(), - isInsideFakeAsync: false, - }; -} diff --git a/packages/schematics/angular/refactor/fake-async/schema.json b/packages/schematics/angular/refactor/fake-async/schema.json deleted file mode 100644 index abc32a4e5441..000000000000 --- a/packages/schematics/angular/refactor/fake-async/schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "title": "Angular `fakeAsync` to Vitest's Fake Timers", - "type": "object", - "properties": { - "project": { - "type": "string", - "description": "The name of the project.", - "$default": { - "$source": "projectName" - } - } - }, - "required": ["project"] -} diff --git a/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts b/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts deleted file mode 100644 index caaab73ccd9d..000000000000 --- a/packages/schematics/angular/refactor/fake-async/transform-fake-async.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @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 '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { createRefactorContext } from './refactor-context'; -import { transformFlush } from './transformers/flush'; -import { - isFakeAsyncCallExpression, - transformSetupFakeTimers, -} from './transformers/setup-fake-timers'; -import { transformTick } from './transformers/tick'; - -/** - * Transforms Angular's `fakeAsync` to Vitest's fake timers. - * Replaces `it('...', fakeAsync(() => { ... }))` with an async test that uses - * `vi.useFakeTimers()` and `onTestFinished` for cleanup. - */ -export function transformFakeAsync(filePath: string, content: string): string { - const sourceFile = ts.createSourceFile( - filePath, - content, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - - const refactorContext = createRefactorContext(); - - const transformer: ts.TransformerFactory = (context) => { - const visit = (node: ts.Node): ts.Node => { - if (ts.isCallExpression(node)) { - const isFakeAsync = isFakeAsyncCallExpression(node); - - for (const transformer of callExpressionTransformers) { - node = transformer(node, refactorContext); - } - - if (isFakeAsync) { - refactorContext.isInsideFakeAsync = true; - } - } - - return ts.visitEachChild(node, visit, context); - }; - - return (sf) => ts.visitNode(sf, visit, ts.isSourceFile); - }; - - const result = ts.transform(sourceFile, [transformer]); - let transformedSourceFile = result.transformed[0]; - - if (refactorContext.importedVitestIdentifiers.size > 0) { - const indentifiers = Array.from(refactorContext.importedVitestIdentifiers); - const vitestImport = ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - undefined, - undefined, - ts.factory.createNamedImports( - indentifiers.map((identifier) => - ts.factory.createImportSpecifier( - false, - undefined, - ts.factory.createIdentifier(identifier), - ), - ), - ), - ), - ts.factory.createStringLiteral('vitest', true), - ); - - transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [ - vitestImport, - ...transformedSourceFile.statements, - ]); - } - - const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); - - return printer.printFile(transformedSourceFile); -} - -const callExpressionTransformers = [transformSetupFakeTimers, transformTick, transformFlush]; diff --git a/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts deleted file mode 100644 index ea6e5ac5b8b8..000000000000 --- a/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../refactor-context'; - -export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): ts.Node { - if (isFakeAsyncCallExpression(node)) { - ctx.importedVitestIdentifiers.add('onTestFinished'); - ctx.importedVitestIdentifiers.add('vi'); - ctx.isInsideFakeAsync = true; - - const callback = node.arguments[0]; - const callbackBody = ts.isBlock(callback.body) - ? callback.body - : ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]); - - // Generate the following code: - // > vi.useFakeTimers(); - // > onTestFinished(() => { - // > vi.useRealTimers(); - // > }); - const setupStatements: ts.Statement[] = [ - ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'useFakeTimers', - ), - undefined, - [], - ), - ), - ts.factory.createExpressionStatement( - ts.factory.createCallExpression(ts.factory.createIdentifier('onTestFinished'), undefined, [ - ts.factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock([ - ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'useRealTimers', - ), - undefined, - [], - ), - ), - ]), - ), - ]), - ), - ...callbackBody.statements, - ]; - - return ts.factory.createArrowFunction( - [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], - undefined, - [], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock(setupStatements), - ); - } - - return node; -} - -export function isFakeAsyncCallExpression(node: ts.Node): node is ts.CallExpression & { - expression: ts.Identifier; - arguments: [ts.ArrowFunction | ts.FunctionExpression]; -} { - return ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'fakeAsync' && - node.arguments.length >= 1 && - (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) - ); -} diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index e9385fcc4521..4ae4077a7be4 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -18,7 +18,6 @@ import { ProjectDefinition, getWorkspace } from '../../utility/workspace'; import { Schema } from './schema'; import { transformJasmineToVitest } from './test-file-transformer'; import { RefactorReporter } from './utils/refactor-reporter'; -import { findTestFiles } from '../../utility/find-test-files'; async function getProject( tree: Tree, @@ -47,6 +46,31 @@ async function getProject( ); } +const DIRECTORIES_TO_SKIP = new Set(['node_modules', '.git', 'dist', '.angular']); + +function findTestFiles(directory: DirEntry, fileSuffix: string): string[] { + const files: string[] = []; + const stack: DirEntry[] = [directory]; + + let current: DirEntry | undefined; + while ((current = stack.pop())) { + for (const path of current.subfiles) { + if (path.endsWith(fileSuffix)) { + files.push(current.path + '/' + path); + } + } + + for (const path of current.subdirs) { + if (DIRECTORIES_TO_SKIP.has(path)) { + continue; + } + stack.push(current.dir(path)); + } + } + + return files; +} + export default function (options: Schema): Rule { return async (tree: Tree, context: SchematicContext) => { const reporter = new RefactorReporter(context.logger); 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 db225a0a4473..c5213b164377 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,9 @@ */ import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { transformFakeAsyncFlush } from './transformers/fake-async-flush'; +import { transformFakeAsyncTest } from './transformers/fake-async-test'; +import { transformFakeAsyncTick } from './transformers/fake-async-tick'; import { transformDoneCallback, transformFocusedAndSkippedTests, @@ -121,6 +124,9 @@ const callExpressionTransformers = [ transformSpyCallInspection, transformtoHaveBeenCalledBefore, transformToHaveClass, + transformFakeAsyncTest, + transformFakeAsyncTick, + transformFakeAsyncFlush, // **Stage 3: Global Functions & Cleanup** // These handle global Jasmine functions and catch-alls for unsupported APIs. 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 6cef7fc3d5ca..03a565e8352e 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 @@ -116,4 +116,24 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { `; await expectTransformation(input, expected, true); }); + + it('should add imports for `onTestFinished` and `vi` when addImports is true', async () => { + const input = ` + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `; + const expected = ` + import { expect, it, onTestFinished, vi } from 'vitest'; + + it('works', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + expect(1).toBe(1); + }); + `; + await expectTransformation(input, expected, true); + }); }); diff --git a/packages/schematics/angular/refactor/fake-async/transformers/flush.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts similarity index 81% rename from packages/schematics/angular/refactor/fake-async/transformers/flush.ts rename to packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts index 6d17a4f0c9f7..266e01d039c0 100644 --- a/packages/schematics/angular/refactor/fake-async/transformers/flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -7,11 +7,9 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../refactor-context'; -export function transformFlush(node: ts.Node, ctx: RefactorContext): ts.Node { +export function transformFakeAsyncFlush(node: ts.Node): ts.Node { if ( - ctx.isInsideFakeAsync && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'flush' 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..f507e26926ed --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush_spec.ts @@ -0,0 +1,25 @@ +/** + * @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: `flush();`, + expected: `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..846d7c166046 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test.ts @@ -0,0 +1,88 @@ +/** + * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../utils/refactor-context'; + +export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts.Node { + if (!isFakeAsyncCallExpression(node)) { + return node; + } + + ctx.pendingVitestValueImports.add('onTestFinished'); + ctx.pendingVitestValueImports.add('vi'); + + const callback = node.arguments[0]; + const callbackBody = ts.isBlock(callback.body) + ? callback.body + : ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]); + + // Generate the following code: + // > vi.useFakeTimers(); + // > onTestFinished(() => { + // > vi.useRealTimers(); + // > }); + const setupStatements: ts.Statement[] = [ + ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'useFakeTimers', + ), + undefined, + [], + ), + ), + ts.factory.createExpressionStatement( + ts.factory.createCallExpression(ts.factory.createIdentifier('onTestFinished'), undefined, [ + ts.factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock([ + ts.factory.createExpressionStatement( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'useRealTimers', + ), + undefined, + [], + ), + ), + ]), + ), + ]), + ), + ...callbackBody.statements, + ]; + + return ts.factory.createArrowFunction( + [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], + undefined, + [], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createBlock(setupStatements), + ); +} + +export function isFakeAsyncCallExpression(node: ts.Node): node is ts.CallExpression & { + expression: ts.Identifier; + arguments: [ts.ArrowFunction | ts.FunctionExpression]; +} { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + node.arguments.length >= 1 && + (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) + ); +} 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..f50d69e986c9 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-test_spec.ts @@ -0,0 +1,37 @@ +/** + * @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('transformFakeTimersTest', () => { + const testCases = [ + { + description: 'should transform fakeAsync test to `vi.useFakeTimers()`', + input: ` + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + expected: ` + it('works', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + expect(1).toBe(1); + }); + `, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/refactor/fake-async/transformers/tick.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts similarity index 84% rename from packages/schematics/angular/refactor/fake-async/transformers/tick.ts rename to packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts index 2cd221463b62..618c133995d1 100644 --- a/packages/schematics/angular/refactor/fake-async/transformers/tick.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick.ts @@ -7,11 +7,9 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../refactor-context'; -export function transformTick(node: ts.Node, ctx: RefactorContext): ts.Node { +export function transformFakeAsyncTick(node: ts.Node): ts.Node { if ( - ctx.isInsideFakeAsync && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'tick' 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..0fb2d8acdebe --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-tick_spec.ts @@ -0,0 +1,30 @@ +/** + * @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: `tick(100);`, + expected: `await vi.advanceTimersByTimeAsync(100);`, + }, + { + description: 'should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', + input: `tick();`, + expected: `await vi.advanceTimersByTimeAsync(0);`, + }, + ]; + + testCases.forEach(({ description, input, expected }) => { + it(description, async () => { + await expectTransformation(input, expected); + }); + }); +}); diff --git a/packages/schematics/angular/utility/find-test-files.ts b/packages/schematics/angular/utility/find-test-files.ts deleted file mode 100644 index dc6ccb71a91a..000000000000 --- a/packages/schematics/angular/utility/find-test-files.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DirEntry } from '@angular-devkit/schematics'; - -const DIRECTORIES_TO_SKIP = new Set(['node_modules', '.git', 'dist', '.angular']); - -export function findTestFiles(directory: DirEntry, fileSuffix: string): string[] { - const files: string[] = []; - const stack: DirEntry[] = [directory]; - - let current: DirEntry | undefined; - while ((current = stack.pop())) { - for (const path of current.subfiles) { - if (path.endsWith(fileSuffix)) { - files.push(current.path + '/' + path); - } - } - - for (const path of current.subdirs) { - if (DIRECTORIES_TO_SKIP.has(path)) { - continue; - } - stack.push(current.dir(path)); - } - } - - return files; -} From 56a3c48ef34ecb1ada37193c7056ae2fc0c4271d Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 10:53:19 +0100 Subject: [PATCH 04/16] refactor(@schematics/angular): report fakeAsync transforms --- .../jasmine-vitest/transformers/fake-async-flush.ts | 9 ++++++++- .../jasmine-vitest/transformers/fake-async-test.ts | 6 ++++++ .../jasmine-vitest/transformers/fake-async-tick.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) 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 index 266e01d039c0..348a51426d8a 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -7,13 +7,20 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../utils/refactor-context'; -export function transformFakeAsyncFlush(node: ts.Node): ts.Node { +export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'flush' ) { + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, + ); + return ts.factory.createAwaitExpression( ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( 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 846d7c166046..42a5dd3ce045 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 @@ -14,6 +14,12 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. return node; } + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`fakeAsync\` to \`vi.useFakeTimers\`.`, + ); + ctx.pendingVitestValueImports.add('onTestFinished'); ctx.pendingVitestValueImports.add('vi'); 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 618c133995d1..a0d629f3f876 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 @@ -7,13 +7,20 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { RefactorContext } from '../utils/refactor-context'; -export function transformFakeAsyncTick(node: ts.Node): ts.Node { +export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'tick' ) { + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, + ); + const durationNumericLiteral = node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) ? node.arguments[0] From 5d0ee773a9411a67073554f480ba442ecf07c67e Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 11:41:30 +0100 Subject: [PATCH 05/16] refactor(@schematics/angular): transform Angular's flush only and remove import --- .../jasmine-vitest/test-file-transformer.ts | 19 +++- .../transformers/fake-async-flush.ts | 10 +- .../transformers/fake-async-flush_spec.ts | 32 +++++- .../jasmine-vitest/utils/ast-helpers.ts | 99 +++++++++++++++++++ .../jasmine-vitest/utils/refactor-context.ts | 16 +++ 5 files changed, 171 insertions(+), 5 deletions(-) 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 c5213b164377..f561931bd848 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -49,7 +49,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'; @@ -185,6 +189,7 @@ export function transformJasmineToVitest( const pendingVitestValueImports = new Set(); const pendingVitestTypeImports = new Set(); + const pendingImportSpecifierRemovals = new Map>(); const transformer: ts.TransformerFactory = (context) => { const refactorCtx: RefactorContext = { @@ -193,6 +198,7 @@ export function transformJasmineToVitest( tsContext: context, pendingVitestValueImports, pendingVitestTypeImports, + pendingImportSpecifierRemovals, }; const visitor: ts.Visitor = (node) => { @@ -246,16 +252,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/transformers/fake-async-flush.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts index 348a51426d8a..4333b4e2f9df 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -7,13 +7,17 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../utils/refactor-context'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; + +const ANGULAR_CORE_TESTING = '@angular/core/testing'; export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - node.expression.text === 'flush' + node.expression.text === 'flush' && + isNamedImportFrom(ctx.sourceFile, 'flush', ANGULAR_CORE_TESTING) ) { ctx.reporter.reportTransformation( ctx.sourceFile, @@ -21,6 +25,8 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, ); + addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); + return ts.factory.createAwaitExpression( ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( 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 index f507e26926ed..22ff342966fd 100644 --- 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 @@ -12,9 +12,39 @@ describe('transformFakeAsyncFlush', () => { const testCases = [ { description: 'should replace `flush` with `await vi.runAllTimersAsync()`', - input: `flush();`, + input: ` + import { flush } from '@angular/core/testing'; + + flush(); + `, expected: `await vi.runAllTimersAsync();`, }, + { + 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(); + `, + }, ]; testCases.forEach(({ description, input, expected }) => { 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 4119d3e0c2c7..139dbe3acce0 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -90,3 +90,102 @@ export function createPropertyAccess( name, ); } + +/** + * 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/refactor-context.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts index a7e705a83ce5..de5cd8f8e6c5 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>; } /** @@ -38,3 +44,13 @@ export type NodeTransformer = ( node: T, refactorCtx: RefactorContext, ) => ts.Node | readonly ts.Node[]; + +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); +} From f8411eed9c429a1c671592547fb86b0821eaa3f4 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 11:58:37 +0100 Subject: [PATCH 06/16] refactor(@schematics/angular): transform fakeAsync and tick only if they're Angular's and remove imports --- .../test-file-transformer_add-imports_spec.ts | 2 ++ .../transformers/fake-async-flush.ts | 3 +- .../transformers/fake-async-test.ts | 30 +++++++++---------- .../transformers/fake-async-test_spec.ts | 21 ++++++++++++- .../transformers/fake-async-tick.ts | 9 ++++-- .../transformers/fake-async-tick_spec.ts | 25 ++++++++++++++-- .../jasmine-vitest/utils/constants.ts | 1 + 7 files changed, 69 insertions(+), 22 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts 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 03a565e8352e..2f78e8089d97 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 @@ -119,6 +119,8 @@ describe('Jasmine to Vitest Transformer - addImports option', () => { it('should add imports for `onTestFinished` and `vi` when addImports is true', async () => { const input = ` + import { fakeAsync } from '@angular/core/testing'; + it('works', fakeAsync(() => { expect(1).toBe(1); })); 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 index 4333b4e2f9df..f6e005d96be6 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -8,10 +8,9 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; -const ANGULAR_CORE_TESTING = '@angular/core/testing'; - export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( ts.isCallExpression(node) && 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 42a5dd3ce045..60ee9fd44834 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 @@ -7,10 +7,21 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../utils/refactor-context'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts.Node { - if (!isFakeAsyncCallExpression(node)) { + if ( + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'fakeAsync' && + node.arguments.length >= 1 && + (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) && + isNamedImportFrom(ctx.sourceFile, 'fakeAsync', ANGULAR_CORE_TESTING) + ) + ) { return node; } @@ -20,6 +31,8 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. `Transformed \`fakeAsync\` to \`vi.useFakeTimers\`.`, ); + addImportSpecifierRemoval(ctx, 'fakeAsync', ANGULAR_CORE_TESTING); + ctx.pendingVitestValueImports.add('onTestFinished'); ctx.pendingVitestValueImports.add('vi'); @@ -79,16 +92,3 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. ts.factory.createBlock(setupStatements), ); } - -export function isFakeAsyncCallExpression(node: ts.Node): node is ts.CallExpression & { - expression: ts.Identifier; - arguments: [ts.ArrowFunction | ts.FunctionExpression]; -} { - return ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'fakeAsync' && - node.arguments.length >= 1 && - (ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0])) - ); -} 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 f50d69e986c9..8ab54bcf4bff 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 @@ -8,11 +8,13 @@ import { expectTransformation } from '../test-helpers'; -describe('transformFakeTimersTest', () => { +describe('transformFakeAsyncTest', () => { const testCases = [ { description: 'should transform fakeAsync test to `vi.useFakeTimers()`', input: ` + import { fakeAsync } from '@angular/core/testing'; + it('works', fakeAsync(() => { expect(1).toBe(1); })); @@ -27,6 +29,23 @@ describe('transformFakeTimersTest', () => { }); `, }, + { + description: 'should not replace `fakeAsync` if not imported from `@angular/core/testing`', + input: ` + import { fakeAsync } from './my-fake-async'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + expected: ` + import { fakeAsync } from './my-fake-async'; + + it('works', fakeAsync(() => { + expect(1).toBe(1); + })); + `, + }, ]; testCases.forEach(({ description, 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 index a0d629f3f876..bb1c8fb834fa 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 @@ -7,13 +7,16 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { RefactorContext } from '../utils/refactor-context'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - node.expression.text === 'tick' + node.expression.text === 'tick' && + isNamedImportFrom(ctx.sourceFile, 'tick', ANGULAR_CORE_TESTING) ) { ctx.reporter.reportTransformation( ctx.sourceFile, @@ -21,6 +24,8 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. `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] 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 0fb2d8acdebe..bd60fad783c5 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 @@ -12,14 +12,35 @@ describe('transformFakeAsyncTick', () => { const testCases = [ { description: 'should replace `tick` with `vi.advanceTimersByTimeAsync`', - input: `tick(100);`, + input: ` + import { tick } from '@angular/core/testing'; + + tick(100); + `, expected: `await vi.advanceTimersByTimeAsync(100);`, }, { description: 'should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', - input: `tick();`, + 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 }) => { 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..d9077f690603 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/constants.ts @@ -0,0 +1 @@ +export const ANGULAR_CORE_TESTING = '@angular/core/testing'; From cc4171c7a64f4f32e4d2947c89ac060829902803 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 12:11:06 +0100 Subject: [PATCH 07/16] refactor(@schematics/angular): transform flushMicrotasks --- .../jasmine-vitest/test-file-transformer.ts | 2 + .../fake-async-flush-microtasks.ts | 42 ++++++++++++++++++ .../fake-async-flush-microtasks_spec.ts | 43 +++++++++++++++++++ 3 files changed, 87 insertions(+) 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 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 f561931bd848..f237f9da7637 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts @@ -15,6 +15,7 @@ import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/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 { @@ -131,6 +132,7 @@ const callExpressionTransformers = [ transformFakeAsyncTest, transformFakeAsyncTick, transformFakeAsyncFlush, + transformFakeAsyncFlushMicrotasks, // **Stage 3: Global Functions & Cleanup** // These handle global Jasmine functions and catch-alls for unsupported APIs. 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..c5d8146aa570 --- /dev/null +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush-microtasks.ts @@ -0,0 +1,42 @@ +/** + * @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 '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; +import { isNamedImportFrom } from '../utils/ast-helpers'; +import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; + +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) + ) { + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, + ); + + addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); + + return ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'advanceTimersByTimeAsync', + ), + undefined, + [ts.factory.createNumericLiteral(0)], + ), + ); + } + + return node; +} 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); + }); + }); +}); From 9fff043a9125d24ee46f6baed1a37f05ebb97ddc Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:31:12 +0100 Subject: [PATCH 08/16] refactor(@schematics/angular): transform flush into `await vi.runAllTimersAsync() ?? 0` if return value is used --- .../transformers/fake-async-flush.ts | 18 ++++++++++++- .../transformers/fake-async-flush_spec.ts | 25 +++++++++++++++++++ .../jasmine-vitest/utils/todo-notes.ts | 7 ++++++ 3 files changed, 49 insertions(+), 1 deletion(-) 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 index f6e005d96be6..341061067faf 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -8,6 +8,7 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; +import { addTodoComment } from '../utils/comment-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; @@ -26,7 +27,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); - return ts.factory.createAwaitExpression( + const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( ts.factory.createIdentifier('vi'), @@ -36,6 +37,21 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts [], ), ); + + if (ts.isStatement(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. + 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), + ); + } } return node; 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 index 22ff342966fd..e4aa5a4ea140 100644 --- 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 @@ -19,6 +19,31 @@ describe('transformFakeAsyncFlush', () => { `, expected: `await vi.runAllTimersAsync();`, }, + // TODO + // { + // 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 not replace `flush` if not imported from `@angular/core/testing`', input: ` 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 8a9d888a0298..e0f0efe73500 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts @@ -127,6 +127,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 3ba7b8af184b8b4644666ab575427116e212dd65 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:32:18 +0100 Subject: [PATCH 09/16] refactor(@schematics/angular): add TODO comment for flush with maxTurns in fakeAsync migration --- .../transformers/fake-async-flush.ts | 5 ++++ .../transformers/fake-async-flush_spec.ts | 23 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) 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 index 341061067faf..2bf45e350406 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -27,6 +27,11 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts 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( ts.factory.createCallExpression( ts.factory.createPropertyAccessExpression( 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 index e4aa5a4ea140..1de5324accfc 100644 --- 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 @@ -19,19 +19,18 @@ describe('transformFakeAsyncFlush', () => { `, expected: `await vi.runAllTimersAsync();`, }, - // TODO - // { - // description: 'should add TODO comment when flush is called with maxTurns', - // input: ` - // import { flush } from '@angular/core/testing'; + { + 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(); - // `, - // }, + 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: ` From fb99070e7f358ae35e433b78598acf2e87224420 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:40:13 +0100 Subject: [PATCH 10/16] refactor(@schematics/angular): maybe import `vi` when tranforming flush or tick --- .../refactor/jasmine-vitest/transformers/fake-async-flush.ts | 1 + .../refactor/jasmine-vitest/transformers/fake-async-tick.ts | 1 + 2 files changed, 2 insertions(+) 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 index 2bf45e350406..1a7a69337872 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -25,6 +25,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, ); + ctx.pendingVitestValueImports.add('vi'); addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); if (node.arguments.length > 0) { 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 bb1c8fb834fa..5449dd3d4aec 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 @@ -24,6 +24,7 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, ); + ctx.pendingVitestValueImports.add('vi'); addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); const durationNumericLiteral = From 7fb4abb0e2e37bc04595cb33930f7eed297dae28 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:42:22 +0100 Subject: [PATCH 11/16] refactor(@schematics/angular): tidy up --- .../transformers/fake-async-flush.ts | 80 ++++++++++--------- .../transformers/fake-async-tick.ts | 56 ++++++------- 2 files changed, 70 insertions(+), 66 deletions(-) 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 index 1a7a69337872..879e1dc1818e 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -14,51 +14,53 @@ import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-co 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) + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flush' && + isNamedImportFrom(ctx.sourceFile, 'flush', ANGULAR_CORE_TESTING) + ) ) { - ctx.reporter.reportTransformation( - ctx.sourceFile, - node, - `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, - ); + return node; + } - ctx.pendingVitestValueImports.add('vi'); - addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, + ); - if (node.arguments.length > 0) { - ctx.reporter.recordTodo('flush-max-turns', ctx.sourceFile, node); - addTodoComment(node, 'flush-max-turns'); - } + ctx.pendingVitestValueImports.add('vi'); + 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( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'runAllTimersAsync', - ), - undefined, - [], + const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'runAllTimersAsync', ), - ); + undefined, + [], + ), + ); - if (ts.isStatement(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. - ctx.reporter.recordTodo('flush-return-value', ctx.sourceFile, node); - addTodoComment(node, 'flush-return-value'); + if (ts.isStatement(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. + 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), - ); - } + return ts.factory.createBinaryExpression( + awaitRunAllTimersAsync, + ts.SyntaxKind.QuestionQuestionToken, + ts.factory.createNumericLiteral(0), + ); } - - return node; } 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 5449dd3d4aec..f86ee0262b46 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 @@ -13,36 +13,38 @@ import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-co 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) + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'tick' && + isNamedImportFrom(ctx.sourceFile, 'tick', ANGULAR_CORE_TESTING) + ) ) { - ctx.reporter.reportTransformation( - ctx.sourceFile, - node, - `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, - ); + return node; + } - ctx.pendingVitestValueImports.add('vi'); - addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, + ); - const durationNumericLiteral = - node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) - ? node.arguments[0] - : ts.factory.createNumericLiteral(0); + ctx.pendingVitestValueImports.add('vi'); + addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); - return ts.factory.createAwaitExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'advanceTimersByTimeAsync', - ), - undefined, - [durationNumericLiteral], - ), - ); - } + const durationNumericLiteral = + node.arguments.length > 0 && ts.isNumericLiteral(node.arguments[0]) + ? node.arguments[0] + : ts.factory.createNumericLiteral(0); - return node; + return ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('vi'), + 'advanceTimersByTimeAsync', + ), + undefined, + [durationNumericLiteral], + ), + ); } From a84bf1f94f567982e72fb2d81c2d276341b86caf Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:45:31 +0100 Subject: [PATCH 12/16] refactor(@schematics/angular): tidy up --- .../fake-async-flush-microtasks.ts | 50 ++++++++++--------- .../transformers/fake-async-flush.ts | 13 ++--- .../transformers/fake-async-test.ts | 17 ++++--- .../transformers/fake-async-tick.ts | 13 ++--- .../jasmine-vitest/utils/refactor-context.ts | 9 ++++ 5 files changed, 59 insertions(+), 43 deletions(-) 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 index c5d8146aa570..39a56e2b9328 100644 --- 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 @@ -9,34 +9,38 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; +import { + RefactorContext, + addImportSpecifierRemoval, + requireVitestIdentifier, +} from '../utils/refactor-context'; 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) + !( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'flushMicrotasks' && + isNamedImportFrom(ctx.sourceFile, 'flushMicrotasks', ANGULAR_CORE_TESTING) + ) ) { - ctx.reporter.reportTransformation( - ctx.sourceFile, - node, - `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, - ); + return node; + } - addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); + ctx.reporter.reportTransformation( + ctx.sourceFile, + node, + `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, + ); - return ts.factory.createAwaitExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'advanceTimersByTimeAsync', - ), - undefined, - [ts.factory.createNumericLiteral(0)], - ), - ); - } + const vi = requireVitestIdentifier(ctx, 'vi'); + addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); - return node; + return ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(vi, 'advanceTimersByTimeAsync'), + undefined, + [ts.factory.createNumericLiteral(0)], + ), + ); } 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 index 879e1dc1818e..4bd057daf9ae 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -10,7 +10,11 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescr import { isNamedImportFrom } from '../utils/ast-helpers'; import { addTodoComment } from '../utils/comment-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; +import { + RefactorContext, + addImportSpecifierRemoval, + requireVitestIdentifier, +} from '../utils/refactor-context'; export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -30,7 +34,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, ); - ctx.pendingVitestValueImports.add('vi'); + const vi = requireVitestIdentifier(ctx, 'vi'); addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); if (node.arguments.length > 0) { @@ -40,10 +44,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'runAllTimersAsync', - ), + ts.factory.createPropertyAccessExpression(vi, 'runAllTimersAsync'), undefined, [], ), 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 60ee9fd44834..83b917065648 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 @@ -9,7 +9,11 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; +import { + RefactorContext, + addImportSpecifierRemoval, + requireVitestIdentifier, +} from '../utils/refactor-context'; export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -33,8 +37,8 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. addImportSpecifierRemoval(ctx, 'fakeAsync', ANGULAR_CORE_TESTING); - ctx.pendingVitestValueImports.add('onTestFinished'); - ctx.pendingVitestValueImports.add('vi'); + const onTestFinished = requireVitestIdentifier(ctx, 'onTestFinished'); + const vi = requireVitestIdentifier(ctx, 'vi'); const callback = node.arguments[0]; const callbackBody = ts.isBlock(callback.body) @@ -49,16 +53,13 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. const setupStatements: ts.Statement[] = [ ts.factory.createExpressionStatement( ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'useFakeTimers', - ), + ts.factory.createPropertyAccessExpression(vi, 'useFakeTimers'), undefined, [], ), ), ts.factory.createExpressionStatement( - ts.factory.createCallExpression(ts.factory.createIdentifier('onTestFinished'), undefined, [ + ts.factory.createCallExpression(onTestFinished, undefined, [ ts.factory.createArrowFunction( undefined, undefined, 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 f86ee0262b46..c22b243f5550 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 @@ -9,7 +9,11 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { RefactorContext, addImportSpecifierRemoval } from '../utils/refactor-context'; +import { + RefactorContext, + addImportSpecifierRemoval, + requireVitestIdentifier, +} from '../utils/refactor-context'; export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -29,7 +33,7 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, ); - ctx.pendingVitestValueImports.add('vi'); + const vi = requireVitestIdentifier(ctx, 'vi'); addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); const durationNumericLiteral = @@ -39,10 +43,7 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. return ts.factory.createAwaitExpression( ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'advanceTimersByTimeAsync', - ), + ts.factory.createPropertyAccessExpression(vi, 'advanceTimersByTimeAsync'), undefined, [durationNumericLiteral], ), 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 de5cd8f8e6c5..976a4b0162aa 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -54,3 +54,12 @@ export function addImportSpecifierRemoval( removals.add(name); ctx.pendingImportSpecifierRemovals.set(moduleSpecifier, removals); } + +export function requireVitestIdentifier( + ctx: RefactorContext, + name: 'onTestFinished' | 'vi', +): ts.Identifier { + ctx.pendingVitestValueImports.add(name); + + return ts.factory.createIdentifier(name); +} From f633960b17c9c933ab4a9dddc6ffea4995a80ae4 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 14:52:37 +0100 Subject: [PATCH 13/16] refactor(@schematics/angular): tidy up --- .../refactor/jasmine-vitest/transformers/fake-async-flush.ts | 3 ++- .../refactor/jasmine-vitest/transformers/fake-async-test.ts | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) 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 index 4bd057daf9ae..006a3d1d133d 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -54,7 +54,8 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts 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. + // 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'); 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 83b917065648..a6bc176de46a 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 @@ -69,10 +69,7 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. ts.factory.createBlock([ ts.factory.createExpressionStatement( ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('vi'), - 'useRealTimers', - ), + ts.factory.createPropertyAccessExpression(vi, 'useRealTimers'), undefined, [], ), From a9585b085a0b8693242a0837abf336595074f197 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 15:10:36 +0100 Subject: [PATCH 14/16] refactor(@schematics/angular): tidy up --- .../fake-async-flush-microtasks.ts | 14 +--- .../transformers/fake-async-flush.ts | 14 +--- .../transformers/fake-async-test.ts | 26 ++------ .../transformers/fake-async-tick.ts | 14 +--- .../transformers/jasmine-misc.ts | 19 +++--- .../transformers/jasmine-spy.ts | 29 +++++---- .../jasmine-vitest/utils/ast-helpers.ts | 13 ---- .../jasmine-vitest/utils/refactor-context.ts | 19 ------ .../jasmine-vitest/utils/refactor-helpers.ts | 65 +++++++++++++++++++ 9 files changed, 104 insertions(+), 109 deletions(-) create mode 100644 packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-helpers.ts 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 index 39a56e2b9328..69861c770586 100644 --- 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 @@ -9,11 +9,8 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { - RefactorContext, - addImportSpecifierRemoval, - requireVitestIdentifier, -} from '../utils/refactor-context'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; export function transformFakeAsyncFlushMicrotasks(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -33,14 +30,9 @@ export function transformFakeAsyncFlushMicrotasks(node: ts.Node, ctx: RefactorCo `Transformed \`flushMicrotasks\` to \`await vi.advanceTimersByTimeAsync(0)\`.`, ); - const vi = requireVitestIdentifier(ctx, 'vi'); addImportSpecifierRemoval(ctx, 'flushMicrotasks', ANGULAR_CORE_TESTING); return ts.factory.createAwaitExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(vi, 'advanceTimersByTimeAsync'), - undefined, - [ts.factory.createNumericLiteral(0)], - ), + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [ts.factory.createNumericLiteral(0)]), ); } 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 index 006a3d1d133d..f3737acf2b55 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -10,11 +10,8 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescr import { isNamedImportFrom } from '../utils/ast-helpers'; import { addTodoComment } from '../utils/comment-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { - RefactorContext, - addImportSpecifierRemoval, - requireVitestIdentifier, -} from '../utils/refactor-context'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -34,7 +31,6 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts `Transformed \`flush\` to \`await vi.runAllTimersAsync()\`.`, ); - const vi = requireVitestIdentifier(ctx, 'vi'); addImportSpecifierRemoval(ctx, 'flush', ANGULAR_CORE_TESTING); if (node.arguments.length > 0) { @@ -43,11 +39,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts } const awaitRunAllTimersAsync = ts.factory.createAwaitExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(vi, 'runAllTimersAsync'), - undefined, - [], - ), + createViCallExpression(ctx, 'runAllTimersAsync'), ); if (ts.isStatement(node.parent)) { 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 a6bc176de46a..6ab32d58418c 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 @@ -9,11 +9,12 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; +import { RefactorContext } from '../utils/refactor-context'; import { - RefactorContext, addImportSpecifierRemoval, + createViCallExpression, requireVitestIdentifier, -} from '../utils/refactor-context'; +} from '../utils/refactor-helpers'; export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -37,27 +38,20 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. addImportSpecifierRemoval(ctx, 'fakeAsync', ANGULAR_CORE_TESTING); - const onTestFinished = requireVitestIdentifier(ctx, 'onTestFinished'); - const vi = requireVitestIdentifier(ctx, 'vi'); - const callback = node.arguments[0]; const callbackBody = ts.isBlock(callback.body) ? callback.body : ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]); + const onTestFinished = requireVitestIdentifier(ctx, 'onTestFinished'); + // Generate the following code: // > vi.useFakeTimers(); // > onTestFinished(() => { // > vi.useRealTimers(); // > }); const setupStatements: ts.Statement[] = [ - ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(vi, 'useFakeTimers'), - undefined, - [], - ), - ), + ts.factory.createExpressionStatement(createViCallExpression(ctx, 'useFakeTimers')), ts.factory.createExpressionStatement( ts.factory.createCallExpression(onTestFinished, undefined, [ ts.factory.createArrowFunction( @@ -67,13 +61,7 @@ export function transformFakeAsyncTest(node: ts.Node, ctx: RefactorContext): ts. undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), ts.factory.createBlock([ - ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(vi, 'useRealTimers'), - undefined, - [], - ), - ), + ts.factory.createExpressionStatement(createViCallExpression(ctx, 'useRealTimers')), ]), ), ]), 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 c22b243f5550..164a00382999 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 @@ -9,11 +9,8 @@ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { isNamedImportFrom } from '../utils/ast-helpers'; import { ANGULAR_CORE_TESTING } from '../utils/constants'; -import { - RefactorContext, - addImportSpecifierRemoval, - requireVitestIdentifier, -} from '../utils/refactor-context'; +import { RefactorContext } from '../utils/refactor-context'; +import { addImportSpecifierRemoval, createViCallExpression } from '../utils/refactor-helpers'; export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts.Node { if ( @@ -33,7 +30,6 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. `Transformed \`tick\` to \`await vi.advanceTimersByTimeAsync()\`.`, ); - const vi = requireVitestIdentifier(ctx, 'vi'); addImportSpecifierRemoval(ctx, 'tick', ANGULAR_CORE_TESTING); const durationNumericLiteral = @@ -42,10 +38,6 @@ export function transformFakeAsyncTick(node: ts.Node, ctx: RefactorContext): ts. : ts.factory.createNumericLiteral(0); return ts.factory.createAwaitExpression( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(vi, 'advanceTimersByTimeAsync'), - undefined, - [durationNumericLiteral], - ), + createViCallExpression(ctx, 'advanceTimersByTimeAsync', [durationNumericLiteral]), ); } 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 2872a3f7503e..773420855e55 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 '../../../third_party/github.com/Microsoft/TypeScript/lib/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) || @@ -71,7 +70,7 @@ export function transformTimerMocks( ]; } - return createViCallExpression(newMethodName, newArgs); + return createViCallExpression(ctx, newMethodName, newArgs); } return node; @@ -101,10 +100,8 @@ export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorC return node; } -export function transformDefaultTimeoutInterval( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformDefaultTimeoutInterval(node: ts.Node, ctx: RefactorContext): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; if ( ts.isExpressionStatement(node) && ts.isBinaryExpression(node.expression) && @@ -124,7 +121,7 @@ export function transformDefaultTimeoutInterval( 'Transformed `jasmine.DEFAULT_TIMEOUT_INTERVAL` to `vi.setConfig()`.', ); const timeoutValue = assignment.right; - 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 1139aedc8aed..f8ed04248d3b 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -14,14 +14,11 @@ */ import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; -import { - addVitestValueImport, - createPropertyAccess, - createViCallExpression, -} from '../utils/ast-helpers'; +import { addVitestValueImport, createPropertyAccess } 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; @@ -194,7 +191,11 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. ); // jasmine.createSpy(name, originalFn) -> vi.fn(originalFn) - return createViCallExpression('fn', node.arguments.length > 1 ? [node.arguments[1]] : []); + return createViCallExpression( + refactorCtx, + 'fn', + node.arguments.length > 1 ? [node.arguments[1]] : [], + ); case 'spyOnAllFunctions': { reporter.reportTransformation( sourceFile, @@ -212,10 +213,8 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. return node; } -export function transformCreateSpyObj( - node: ts.Node, - { sourceFile, reporter, pendingVitestValueImports }: RefactorContext, -): ts.Node { +export function transformCreateSpyObj(node: ts.Node, ctx: RefactorContext): ts.Node { + const { sourceFile, reporter, pendingVitestValueImports } = ctx; if (!isJasmineCallExpression(node, 'createSpyObj')) { return node; } @@ -243,9 +242,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); @@ -268,13 +267,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; @@ -298,6 +298,7 @@ function createSpyObjWithArray( } function createSpyObjWithObject( + ctx: RefactorContext, methods: ts.ObjectLiteralExpression, baseName: string | undefined, ): ts.PropertyAssignment[] { @@ -306,7 +307,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 139dbe3acce0..d125f5e37a36 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/ast-helpers.ts @@ -59,19 +59,6 @@ export function getVitestAutoImports( ); } -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, 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 976a4b0162aa..08b842b319bc 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-context.ts @@ -44,22 +44,3 @@ export type NodeTransformer = ( node: T, refactorCtx: RefactorContext, ) => ts.Node | readonly ts.Node[]; - -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); -} - -export function requireVitestIdentifier( - ctx: RefactorContext, - name: 'onTestFinished' | 'vi', -): ts.Identifier { - ctx.pendingVitestValueImports.add(name); - - return ts.factory.createIdentifier(name); -} 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..9fde1229aa1f --- /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 '../../../third_party/github.com/Microsoft/TypeScript/lib/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); +} From f52a70bdc97e83b63c01fd714a9f80cd1e53750b Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 15:34:01 +0100 Subject: [PATCH 15/16] refactor(@schematics/angular): handle case where flush() is used in return statement --- .../transformers/fake-async-flush.ts | 2 +- .../transformers/fake-async-flush_spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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 index f3737acf2b55..1974450a4873 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/fake-async-flush.ts @@ -42,7 +42,7 @@ export function transformFakeAsyncFlush(node: ts.Node, ctx: RefactorContext): ts createViCallExpression(ctx, 'runAllTimersAsync'), ); - if (ts.isStatement(node.parent)) { + if (ts.isExpressionStatement(node.parent)) { return awaitRunAllTimersAsync; } else { // If `flush` is not used as its own statement, then the return value is probably used. 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 index 1de5324accfc..59cacb190b48 100644 --- 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 @@ -43,6 +43,22 @@ describe('transformFakeAsyncFlush', () => { 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: ` From c16d6cd8520e2c389a8a76dedecc86e782e118d2 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 19 Mar 2026 15:48:57 +0100 Subject: [PATCH 16/16] test(@schematics/angular): add test --- .../transformers/fake-async-flush_spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index 59cacb190b48..1daeda891461 100644 --- 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 @@ -82,6 +82,19 @@ describe('transformFakeAsyncFlush', () => { 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(); `, },