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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't flush be in a helper method? if the flush is the one from @angular/core/testing I would think it should be transformed since it would throw if it wasn't in fakeAsync somewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This answers one on two subsequent questions:

  • shouldn't we only transform identifiers if source is @angular/core/testing? I assume that we should as there are higher chances of name collisions than users importing the real tick / flush through a custom barrel module (e.g. import { tick, flush } from 'my-custom-barrel-that-reexports-angular-core-testing')
  • no need to handle things like aliased imports of tick and flush, right? Sounds too much of an edge-case to me.

}));
`);

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