Skip to content

Commit 8beba0c

Browse files
committed
feat(@schematics/angular): add refactor-fake-async migration
1 parent 67cd62e commit 8beba0c

File tree

12 files changed

+427
-32
lines changed

12 files changed

+427
-32
lines changed

packages/schematics/angular/collection.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@
138138
"private": true,
139139
"description": "[INTERNAL] Adds tailwind to a project. Intended for use for ng new/add."
140140
},
141+
"refactor-fake-async": {
142+
"factory": "./refactor/fake-async",
143+
"schema": "./refactor/fake-async/schema.json",
144+
"description": "[EXPERIMENTAL] Refactors Angular fakeAsync to Vitest fake timers.",
145+
"hidden": true
146+
},
141147
"refactor-jasmine-vitest": {
142148
"factory": "./refactor/jasmine-vitest",
143149
"schema": "./refactor/jasmine-vitest/schema.json",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
10+
import { findTestFiles } from '../../utility/find-test-files';
11+
import { getWorkspace } from '../../utility/workspace';
12+
import { Schema as FakeAsyncOptions } from './schema';
13+
import { transformFakeAsync } from './transform-fake-async';
14+
15+
export default function (options: FakeAsyncOptions): Rule {
16+
return async (tree: Tree, _context: SchematicContext) => {
17+
const workspace = await getWorkspace(tree);
18+
const project = workspace.projects.get(options.project);
19+
20+
if (!project) {
21+
throw new SchematicsException(`Project "${options.project}" does not exist.`);
22+
}
23+
24+
const projectRoot = project.root;
25+
const fileSuffix = '.spec.ts';
26+
const files = findTestFiles(tree.getDir(projectRoot), fileSuffix);
27+
28+
if (files.length === 0) {
29+
throw new SchematicsException(
30+
`No files ending with '${fileSuffix}' found in ${projectRoot}.`,
31+
);
32+
}
33+
34+
for (const file of files) {
35+
const content = tree.readText(file);
36+
const newContent = transformFakeAsync(file, content);
37+
38+
if (content !== newContent) {
39+
tree.overwrite(file, newContent);
40+
}
41+
}
42+
};
43+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
10+
import { format } from 'prettier';
11+
import { Schema as ApplicationOptions } from '../../application/schema';
12+
import { Schema as WorkspaceOptions } from '../../workspace/schema';
13+
14+
describe('Angular `fakeAsync` to Vitest Fake Timers Schematic', () => {
15+
it("should replace `fakeAsync` with an async test using Vitest's fake timers", async () => {
16+
const { transformContent } = await setUp();
17+
18+
const newContent = await transformContent(`
19+
it("should work", fakeAsync(() => {
20+
expect(1).toBe(1);
21+
}));
22+
`);
23+
24+
expect(newContent).toBe(`\
25+
import { onTestFinished, vi } from "vitest";
26+
it("should work", async () => {
27+
vi.useFakeTimers();
28+
onTestFinished(() => {
29+
vi.useRealTimers();
30+
});
31+
expect(1).toBe(1);
32+
});
33+
`);
34+
});
35+
36+
it('should replace `tick` with `vi.advanceTimersByTimeAsync`', async () => {
37+
const { transformContent } = await setUp();
38+
39+
const newContent = await transformContent(`
40+
it("should work", fakeAsync(() => {
41+
tick(100);
42+
}));
43+
`);
44+
45+
expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(100);`);
46+
});
47+
48+
it('should replace `tick()` with `vi.advanceTimersByTimeAsync(0)`', async () => {
49+
const { transformContent } = await setUp();
50+
51+
const newContent = await transformContent(`
52+
it("should work", fakeAsync(() => {
53+
tick();
54+
}));
55+
`);
56+
57+
expect(newContent).toContain(`await vi.advanceTimersByTimeAsync(0);`);
58+
});
59+
60+
it('should replace `flush` with `vi.advanceTimersByTimeAsync`', async () => {
61+
const { transformContent } = await setUp();
62+
63+
const newContent = await transformContent(`
64+
it("should work", fakeAsync(() => {
65+
flush();
66+
}));
67+
`);
68+
69+
expect(newContent).toContain(`await vi.runAllTimersAsync();`);
70+
});
71+
});
72+
73+
async function setUp() {
74+
const schematicRunner = new SchematicTestRunner(
75+
'@schematics/angular',
76+
require.resolve('../../collection.json'),
77+
);
78+
79+
const workspaceOptions: WorkspaceOptions = {
80+
name: 'workspace',
81+
newProjectRoot: 'projects',
82+
version: '21.0.0',
83+
};
84+
85+
const appOptions: ApplicationOptions = {
86+
name: 'bar',
87+
inlineStyle: false,
88+
inlineTemplate: false,
89+
routing: false,
90+
skipTests: false,
91+
skipPackageJson: false,
92+
};
93+
94+
let appTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
95+
appTree = await schematicRunner.runSchematic('application', appOptions, appTree);
96+
97+
return {
98+
transformContent: async (content: string): Promise<string> => {
99+
const specFilePath = 'projects/bar/src/app/app.spec.ts';
100+
101+
appTree.overwrite(specFilePath, content);
102+
103+
const tree = await schematicRunner.runSchematic(
104+
'refactor-fake-async',
105+
{ project: 'bar' },
106+
appTree,
107+
);
108+
109+
return format(tree.readContent(specFilePath), { parser: 'typescript' });
110+
},
111+
};
112+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export interface RefactorContext {
10+
importedVitestIdentifiers: Set<string>;
11+
}
12+
13+
export function createRefactorContext(): RefactorContext {
14+
return {
15+
importedVitestIdentifiers: new Set(),
16+
};
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"title": "Angular `fakeAsync` to Vitest's Fake Timers",
4+
"type": "object",
5+
"properties": {
6+
"project": {
7+
"type": "string",
8+
"description": "The name of the project.",
9+
"$default": {
10+
"$source": "projectName"
11+
}
12+
}
13+
},
14+
"required": ["project"]
15+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be found
6+
* in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
10+
import { createRefactorContext } from './refactor-context';
11+
import { transformFlush } from './transformers/flush';
12+
import { transformSetupFakeTimers } from './transformers/setup-fake-timers';
13+
import { transformTick } from './transformers/tick';
14+
15+
/**
16+
* Transforms Angular's `fakeAsync` to Vitest's fake timers.
17+
* Replaces `it('...', fakeAsync(() => { ... }))` with an async test that uses
18+
* `vi.useFakeTimers()` and `onTestFinished` for cleanup.
19+
*/
20+
export function transformFakeAsync(filePath: string, content: string): string {
21+
const sourceFile = ts.createSourceFile(
22+
filePath,
23+
content,
24+
ts.ScriptTarget.Latest,
25+
true,
26+
ts.ScriptKind.TS,
27+
);
28+
29+
const refactorContext = createRefactorContext();
30+
31+
const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
32+
const visit = (node: ts.Node): ts.Node => {
33+
if (ts.isCallExpression(node)) {
34+
for (const transformer of callExpressionTransformers) {
35+
node = transformer(node, refactorContext);
36+
}
37+
}
38+
39+
return ts.visitEachChild(node, visit, context);
40+
};
41+
42+
return (sf) => ts.visitNode(sf, visit, ts.isSourceFile);
43+
};
44+
45+
const result = ts.transform(sourceFile, [transformer]);
46+
let transformedSourceFile = result.transformed[0];
47+
48+
if (refactorContext.importedVitestIdentifiers.size > 0) {
49+
const indentifiers = Array.from(refactorContext.importedVitestIdentifiers);
50+
const vitestImport = ts.factory.createImportDeclaration(
51+
undefined,
52+
ts.factory.createImportClause(
53+
undefined,
54+
undefined,
55+
ts.factory.createNamedImports(
56+
indentifiers.map((identifier) =>
57+
ts.factory.createImportSpecifier(
58+
false,
59+
undefined,
60+
ts.factory.createIdentifier(identifier),
61+
),
62+
),
63+
),
64+
),
65+
ts.factory.createStringLiteral('vitest', true),
66+
);
67+
68+
transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [
69+
vitestImport,
70+
...transformedSourceFile.statements,
71+
]);
72+
}
73+
74+
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
75+
76+
return printer.printFile(transformedSourceFile);
77+
}
78+
79+
const callExpressionTransformers = [transformSetupFakeTimers, transformTick, transformFlush];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
2+
3+
export function transformFlush(node: ts.Node): ts.Node {
4+
if (
5+
ts.isCallExpression(node) &&
6+
ts.isIdentifier(node.expression) &&
7+
node.expression.text === 'flush'
8+
) {
9+
return ts.factory.createAwaitExpression(
10+
ts.factory.createCallExpression(
11+
ts.factory.createPropertyAccessExpression(
12+
ts.factory.createIdentifier('vi'),
13+
'runAllTimersAsync',
14+
),
15+
undefined,
16+
[],
17+
),
18+
);
19+
}
20+
21+
return node;
22+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
2+
import { RefactorContext } from '../refactor-context';
3+
4+
export function transformSetupFakeTimers(node: ts.Node, ctx: RefactorContext): ts.Node {
5+
if (
6+
ts.isCallExpression(node) &&
7+
ts.isIdentifier(node.expression) &&
8+
node.expression.text === 'fakeAsync' &&
9+
node.arguments.length >= 1 &&
10+
(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]))
11+
) {
12+
ctx.importedVitestIdentifiers.add('onTestFinished');
13+
ctx.importedVitestIdentifiers.add('vi');
14+
15+
const callback = node.arguments[0];
16+
const callbackBody = ts.isBlock(callback.body)
17+
? callback.body
18+
: ts.factory.createBlock([ts.factory.createExpressionStatement(callback.body)]);
19+
20+
// Generate the following code:
21+
// vi.useFakeTimers();
22+
// onTestFinished(() => {
23+
// vi.useRealTimers();
24+
// });
25+
const setupStatements: ts.Statement[] = [
26+
ts.factory.createExpressionStatement(
27+
ts.factory.createCallExpression(
28+
ts.factory.createPropertyAccessExpression(
29+
ts.factory.createIdentifier('vi'),
30+
'useFakeTimers',
31+
),
32+
undefined,
33+
[],
34+
),
35+
),
36+
ts.factory.createExpressionStatement(
37+
ts.factory.createCallExpression(ts.factory.createIdentifier('onTestFinished'), undefined, [
38+
ts.factory.createArrowFunction(
39+
undefined,
40+
undefined,
41+
[],
42+
undefined,
43+
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
44+
ts.factory.createBlock([
45+
ts.factory.createExpressionStatement(
46+
ts.factory.createCallExpression(
47+
ts.factory.createPropertyAccessExpression(
48+
ts.factory.createIdentifier('vi'),
49+
'useRealTimers',
50+
),
51+
undefined,
52+
[],
53+
),
54+
),
55+
]),
56+
),
57+
]),
58+
),
59+
...callbackBody.statements,
60+
];
61+
62+
return ts.factory.createArrowFunction(
63+
[ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)],
64+
undefined,
65+
[],
66+
undefined,
67+
ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
68+
ts.factory.createBlock(setupStatements),
69+
);
70+
}
71+
72+
return node;
73+
}

0 commit comments

Comments
 (0)