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..24d433b446e9 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/index_spec.ts @@ -0,0 +1,136 @@ +/** + * @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 new file mode 100644 index 000000000000..5f43df449e21 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/refactor-context.ts @@ -0,0 +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 + */ + +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 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..caaab73ccd9d --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transform-fake-async.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 { 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/flush.ts b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts new file mode 100644 index 000000000000..6d17a4f0c9f7 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/flush.ts @@ -0,0 +1,32 @@ +/** + * @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, ctx: RefactorContext): ts.Node { + if ( + ctx.isInsideFakeAsync && + 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..ea6e5ac5b8b8 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/setup-fake-timers.ts @@ -0,0 +1,89 @@ +/** + * @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/fake-async/transformers/tick.ts b/packages/schematics/angular/refactor/fake-async/transformers/tick.ts new file mode 100644 index 000000000000..2cd221463b62 --- /dev/null +++ b/packages/schematics/angular/refactor/fake-async/transformers/tick.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 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 { + if ( + ctx.isInsideFakeAsync && + 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; +}