Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/schematics/angular/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions packages/schematics/angular/refactor/fake-async/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
};
}
136 changes: 136 additions & 0 deletions packages/schematics/angular/refactor/fake-async/index_spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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' });
},
};
}
Original file line number Diff line number Diff line change
@@ -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<string>;
isInsideFakeAsync: boolean;
}

export function createRefactorContext(): RefactorContext {
return {
importedVitestIdentifiers: new Set(),
isInsideFakeAsync: false,
};
}
15 changes: 15 additions & 0 deletions packages/schematics/angular/refactor/fake-async/schema.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
@@ -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<ts.SourceFile> = (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];
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading