Skip to content

Commit f89750b

Browse files
committed
fix(@schematics/angular): add addImports option to jasmine-vitest schematic
Introduces a new `--add-imports` boolean option to the Jasmine to Vitest refactoring schematic. When this option is set to `true`, the schematic will automatically add a single, consolidated `import { ... } from 'vitest';` statement at the top of the transformed file for any Vitest APIs that are used. Key changes: - Type-only imports (e.g., `Mock`, `MockedObject`) are now always added to ensure the refactored code remains type-correct. - Value imports (e.g., `vi`, `describe`, `it`, `expect`) are only added when `--add-imports` is `true`. - The import generation logic correctly handles all scenarios, including type-only imports (`import type {...}`), value-only imports, and combined imports with inline `type` keywords. - A new test suite has been added to validate the import generation logic under various conditions. (cherry picked from commit cc132e9)
1 parent d5bae29 commit f89750b

File tree

12 files changed

+184
-44
lines changed

12 files changed

+184
-44
lines changed

packages/schematics/angular/refactor/jasmine-vitest/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ export default function (options: Schema): Rule {
119119
for (const file of files) {
120120
reporter.incrementScannedFiles();
121121
const content = tree.readText(file);
122-
const newContent = transformJasmineToVitest(file, content, reporter);
122+
const newContent = transformJasmineToVitest(file, content, reporter, {
123+
addImports: !!options.addImports,
124+
});
123125

124126
if (content !== newContent) {
125127
tree.overwrite(file, newContent);

packages/schematics/angular/refactor/jasmine-vitest/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
"type": "boolean",
2626
"description": "Enable verbose logging to see detailed information about the transformations being applied.",
2727
"default": false
28+
},
29+
"addImports": {
30+
"type": "boolean",
31+
"description": "Whether to add imports for the Vitest API. The Angular `unit-test` system automatically uses the Vitest globals option, which means explicit imports for global APIs like `describe`, `it`, `expect`, and `vi` are often not strictly necessary unless Vitest has been configured not to use globals.",
32+
"default": false
2833
}
2934
}
3035
}

packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.integration_spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { RefactorReporter } from './utils/refactor-reporter';
1414
async function expectTransformation(input: string, expected: string): Promise<void> {
1515
const logger = new logging.NullLogger();
1616
const reporter = new RefactorReporter(logger);
17-
const transformed = transformJasmineToVitest('spec.ts', input, reporter);
17+
const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports: false });
1818
const formattedTransformed = await format(transformed, { parser: 'typescript' });
1919
const formattedExpected = await format(expected, { parser: 'typescript' });
2020

packages/schematics/angular/refactor/jasmine-vitest/test-file-transformer.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
transformSpyReset,
4040
} from './transformers/jasmine-spy';
4141
import { transformJasmineTypes } from './transformers/jasmine-type';
42-
import { getVitestAutoImports } from './utils/ast-helpers';
42+
import { addVitestValueImport, getVitestAutoImports } from './utils/ast-helpers';
4343
import { RefactorContext } from './utils/refactor-context';
4444
import { RefactorReporter } from './utils/refactor-reporter';
4545

@@ -61,14 +61,17 @@ function restoreBlankLines(content: string): string {
6161
/**
6262
* Transforms a string of Jasmine test code to Vitest test code.
6363
* This is the main entry point for the transformation.
64+
* @param filePath The path to the file being transformed.
6465
* @param content The source code to transform.
6566
* @param reporter The reporter to track TODOs.
67+
* @param options Transformation options.
6668
* @returns The transformed code.
6769
*/
6870
export function transformJasmineToVitest(
6971
filePath: string,
7072
content: string,
7173
reporter: RefactorReporter,
74+
options: { addImports: boolean },
7275
): string {
7376
const contentWithPlaceholders = preserveBlankLines(content);
7477

@@ -80,20 +83,29 @@ export function transformJasmineToVitest(
8083
ts.ScriptKind.TS,
8184
);
8285

83-
const pendingVitestImports = new Set<string>();
86+
const pendingVitestValueImports = new Set<string>();
87+
const pendingVitestTypeImports = new Set<string>();
8488
const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
8589
const refactorCtx: RefactorContext = {
8690
sourceFile,
8791
reporter,
8892
tsContext: context,
89-
pendingVitestImports,
93+
pendingVitestValueImports,
94+
pendingVitestTypeImports,
9095
};
9196

9297
const visitor: ts.Visitor = (node) => {
9398
let transformedNode: ts.Node | readonly ts.Node[] = node;
9499

95100
// Transform the node itself based on its type
96101
if (ts.isCallExpression(transformedNode)) {
102+
if (options.addImports && ts.isIdentifier(transformedNode.expression)) {
103+
const name = transformedNode.expression.text;
104+
if (name === 'describe' || name === 'it' || name === 'expect') {
105+
addVitestValueImport(pendingVitestValueImports, name);
106+
}
107+
}
108+
97109
const transformations = [
98110
// **Stage 1: High-Level & Context-Sensitive Transformations**
99111
// These transformers often wrap or fundamentally change the nature of the call,
@@ -171,16 +183,29 @@ export function transformJasmineToVitest(
171183
const result = ts.transform(sourceFile, [transformer]);
172184
let transformedSourceFile = result.transformed[0];
173185

174-
if (transformedSourceFile === sourceFile && !reporter.hasTodos && !pendingVitestImports.size) {
186+
const hasPendingValueImports = pendingVitestValueImports.size > 0;
187+
const hasPendingTypeImports = pendingVitestTypeImports.size > 0;
188+
189+
if (
190+
transformedSourceFile === sourceFile &&
191+
!reporter.hasTodos &&
192+
!hasPendingValueImports &&
193+
!hasPendingTypeImports
194+
) {
175195
return content;
176196
}
177197

178-
const vitestImport = getVitestAutoImports(pendingVitestImports);
179-
if (vitestImport) {
180-
transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [
181-
vitestImport,
182-
...transformedSourceFile.statements,
183-
]);
198+
if (hasPendingTypeImports || (options.addImports && hasPendingValueImports)) {
199+
const vitestImport = getVitestAutoImports(
200+
options.addImports ? pendingVitestValueImports : new Set(),
201+
pendingVitestTypeImports,
202+
);
203+
if (vitestImport) {
204+
transformedSourceFile = ts.factory.updateSourceFile(transformedSourceFile, [
205+
vitestImport,
206+
...transformedSourceFile.statements,
207+
]);
208+
}
184209
}
185210

186211
const printer = ts.createPrinter();
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 { expectTransformation } from './test-helpers';
10+
11+
describe('Jasmine to Vitest Transformer', () => {
12+
describe('addImports option', () => {
13+
it('should add value imports when addImports is true', async () => {
14+
const input = `spyOn(foo, 'bar');`;
15+
const expected = `
16+
import { vi } from 'vitest';
17+
vi.spyOn(foo, 'bar');
18+
`;
19+
await expectTransformation(input, expected, true);
20+
});
21+
22+
it('should generate a single, combined import for value and type imports when addImports is true', async () => {
23+
const input = `
24+
let mySpy: jasmine.Spy;
25+
spyOn(foo, 'bar');
26+
`;
27+
const expected = `
28+
import { type Mock, vi } from 'vitest';
29+
30+
let mySpy: Mock;
31+
vi.spyOn(foo, 'bar');
32+
`;
33+
await expectTransformation(input, expected, true);
34+
});
35+
36+
it('should only add type imports when addImports is false', async () => {
37+
const input = `
38+
let mySpy: jasmine.Spy;
39+
spyOn(foo, 'bar');
40+
`;
41+
const expected = `
42+
import type { Mock } from 'vitest';
43+
44+
let mySpy: Mock;
45+
vi.spyOn(foo, 'bar');
46+
`;
47+
await expectTransformation(input, expected, false);
48+
});
49+
50+
it('should not add an import if no Vitest APIs are used, even when addImports is true', async () => {
51+
const input = `const a = 1;`;
52+
const expected = `const a = 1;`;
53+
await expectTransformation(input, expected, true);
54+
});
55+
});
56+
});

packages/schematics/angular/refactor/jasmine-vitest/test-helpers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ import { RefactorReporter } from './utils/refactor-reporter';
2323
* @param input The Jasmine code snippet to be transformed.
2424
* @param expected The expected Vitest code snippet after transformation.
2525
*/
26-
export async function expectTransformation(input: string, expected: string): Promise<void> {
26+
export async function expectTransformation(
27+
input: string,
28+
expected: string,
29+
addImports = false,
30+
): Promise<void> {
2731
const logger = new logging.NullLogger();
2832
const reporter = new RefactorReporter(logger);
29-
const transformed = transformJasmineToVitest('spec.ts', input, reporter);
33+
const transformed = transformJasmineToVitest('spec.ts', input, reporter, { addImports });
3034
const formattedTransformed = await format(transformed, { parser: 'typescript' });
3135
const formattedExpected = await format(expected, { parser: 'typescript' });
3236

packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
*/
1616

1717
import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
18-
import { createExpectCallExpression, createPropertyAccess } from '../utils/ast-helpers';
18+
import {
19+
addVitestValueImport,
20+
createExpectCallExpression,
21+
createPropertyAccess,
22+
} from '../utils/ast-helpers';
1923
import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation';
2024
import { addTodoComment } from '../utils/comment-helpers';
2125
import { RefactorContext } from '../utils/refactor-context';
@@ -94,7 +98,7 @@ const ASYMMETRIC_MATCHER_NAMES: ReadonlyArray<string> = [
9498

9599
export function transformAsymmetricMatchers(
96100
node: ts.Node,
97-
{ sourceFile, reporter }: RefactorContext,
101+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
98102
): ts.Node {
99103
if (
100104
ts.isPropertyAccessExpression(node) &&
@@ -103,6 +107,7 @@ export function transformAsymmetricMatchers(
103107
) {
104108
const matcherName = node.name.text;
105109
if (ASYMMETRIC_MATCHER_NAMES.includes(matcherName)) {
110+
addVitestValueImport(pendingVitestValueImports, 'expect');
106111
reporter.reportTransformation(
107112
sourceFile,
108113
node,

packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
*/
1515

1616
import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
17-
import { createViCallExpression } from '../utils/ast-helpers';
17+
import { addVitestValueImport, createViCallExpression } from '../utils/ast-helpers';
1818
import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation';
1919
import { addTodoComment } from '../utils/comment-helpers';
2020
import { RefactorContext } from '../utils/refactor-context';
2121
import { TodoCategory } from '../utils/todo-notes';
2222

2323
export function transformTimerMocks(
2424
node: ts.Node,
25-
{ sourceFile, reporter }: RefactorContext,
25+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
2626
): ts.Node {
2727
if (
2828
!ts.isCallExpression(node) ||
@@ -55,6 +55,7 @@ export function transformTimerMocks(
5555
}
5656

5757
if (newMethodName) {
58+
addVitestValueImport(pendingVitestValueImports, 'vi');
5859
reporter.reportTransformation(
5960
sourceFile,
6061
node,
@@ -94,7 +95,7 @@ export function transformFail(node: ts.Node, { sourceFile, reporter }: RefactorC
9495

9596
export function transformDefaultTimeoutInterval(
9697
node: ts.Node,
97-
{ sourceFile, reporter }: RefactorContext,
98+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
9899
): ts.Node {
99100
if (
100101
ts.isExpressionStatement(node) &&
@@ -108,6 +109,7 @@ export function transformDefaultTimeoutInterval(
108109
assignment.left.expression.text === 'jasmine' &&
109110
assignment.left.name.text === 'DEFAULT_TIMEOUT_INTERVAL'
110111
) {
112+
addVitestValueImport(pendingVitestValueImports, 'vi');
111113
reporter.reportTransformation(
112114
sourceFile,
113115
node,

packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414
*/
1515

1616
import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
17-
import { createPropertyAccess, createViCallExpression } from '../utils/ast-helpers';
17+
import {
18+
addVitestValueImport,
19+
createPropertyAccess,
20+
createViCallExpression,
21+
} from '../utils/ast-helpers';
1822
import { getJasmineMethodName, isJasmineCallExpression } from '../utils/ast-validation';
1923
import { addTodoComment } from '../utils/comment-helpers';
2024
import { RefactorContext } from '../utils/refactor-context';
2125

2226
export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.Node {
23-
const { sourceFile, reporter } = refactorCtx;
27+
const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx;
2428
if (!ts.isCallExpression(node)) {
2529
return node;
2630
}
@@ -29,6 +33,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.
2933
ts.isIdentifier(node.expression) &&
3034
(node.expression.text === 'spyOn' || node.expression.text === 'spyOnProperty')
3135
) {
36+
addVitestValueImport(pendingVitestValueImports, 'vi');
3237
reporter.reportTransformation(
3338
sourceFile,
3439
node,
@@ -181,6 +186,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.
181186
const jasmineMethodName = getJasmineMethodName(node);
182187
switch (jasmineMethodName) {
183188
case 'createSpy':
189+
addVitestValueImport(pendingVitestValueImports, 'vi');
184190
reporter.reportTransformation(
185191
sourceFile,
186192
node,
@@ -208,12 +214,13 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts.
208214

209215
export function transformCreateSpyObj(
210216
node: ts.Node,
211-
{ sourceFile, reporter }: RefactorContext,
217+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
212218
): ts.Node {
213219
if (!isJasmineCallExpression(node, 'createSpyObj')) {
214220
return node;
215221
}
216222

223+
addVitestValueImport(pendingVitestValueImports, 'vi');
217224
reporter.reportTransformation(
218225
sourceFile,
219226
node,
@@ -328,7 +335,11 @@ function getSpyIdentifierFromCalls(node: ts.PropertyAccessExpression): ts.Expres
328335
return undefined;
329336
}
330337

331-
function createMockedSpyMockProperty(spyIdentifier: ts.Expression): ts.PropertyAccessExpression {
338+
function createMockedSpyMockProperty(
339+
spyIdentifier: ts.Expression,
340+
pendingVitestValueImports: Set<string>,
341+
): ts.PropertyAccessExpression {
342+
addVitestValueImport(pendingVitestValueImports, 'vi');
332343
const mockedSpy = ts.factory.createCallExpression(
333344
createPropertyAccess('vi', 'mocked'),
334345
undefined,
@@ -340,7 +351,7 @@ function createMockedSpyMockProperty(spyIdentifier: ts.Expression): ts.PropertyA
340351

341352
function transformMostRecentArgs(
342353
node: ts.Node,
343-
{ sourceFile, reporter }: RefactorContext,
354+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
344355
): ts.Node {
345356
// Check 1: Is it a property access for `.args`?
346357
if (
@@ -382,7 +393,7 @@ function transformMostRecentArgs(
382393
node,
383394
'Transformed `spy.calls.mostRecent().args` to `vi.mocked(spy).mock.lastCall`.',
384395
);
385-
const mockProperty = createMockedSpyMockProperty(spyIdentifier);
396+
const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports);
386397

387398
return createPropertyAccess(mockProperty, 'lastCall');
388399
}
@@ -397,15 +408,15 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC
397408
return node;
398409
}
399410

400-
const { sourceFile, reporter } = refactorCtx;
411+
const { sourceFile, reporter, pendingVitestValueImports } = refactorCtx;
401412

402413
const pae = node.expression; // e.g., mySpy.calls.count
403414
const spyIdentifier = ts.isPropertyAccessExpression(pae.expression)
404415
? getSpyIdentifierFromCalls(pae.expression)
405416
: undefined;
406417

407418
if (spyIdentifier) {
408-
const mockProperty = createMockedSpyMockProperty(spyIdentifier);
419+
const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports);
409420
const callsProperty = createPropertyAccess(mockProperty, 'calls');
410421

411422
const callName = pae.name.text;

0 commit comments

Comments
 (0)