Skip to content

Commit ede5e52

Browse files
committed
feat(@schematics/angular): add include option to jasmine-to-vitest schematic
This commit introduces a new `--include` option to the `jasmine-to-vitest` refactoring schematic. This option allows users to scope the transformation to a specific file or directory within a project. Previously, the schematic would always process every test file in the entire project. With the `include` option, users can now run the refactoring on a smaller subset of files, which is useful for incremental migrations or for targeting specific areas of a codebase. The implementation handles both file and directory paths, normalizes Windows-style path separators, and includes error handling for invalid or non-existent paths.
1 parent 92ddc42 commit ede5e52

File tree

3 files changed

+143
-20
lines changed

3 files changed

+143
-20
lines changed

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

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,37 @@ import {
1313
SchematicsException,
1414
Tree,
1515
} from '@angular-devkit/schematics';
16+
import { join, normalize } from 'node:path/posix';
1617
import { ProjectDefinition, getWorkspace } from '../../utility/workspace';
1718
import { Schema } from './schema';
1819
import { transformJasmineToVitest } from './test-file-transformer';
1920
import { RefactorReporter } from './utils/refactor-reporter';
2021

21-
async function getProjectRoot(tree: Tree, projectName: string | undefined): Promise<string> {
22+
async function getProject(
23+
tree: Tree,
24+
projectName: string | undefined,
25+
): Promise<{ project: ProjectDefinition; name: string }> {
2226
const workspace = await getWorkspace(tree);
2327

24-
let project: ProjectDefinition | undefined;
2528
if (projectName) {
26-
project = workspace.projects.get(projectName);
29+
const project = workspace.projects.get(projectName);
2730
if (!project) {
2831
throw new SchematicsException(`Project "${projectName}" not found.`);
2932
}
30-
} else {
31-
if (workspace.projects.size === 1) {
32-
project = workspace.projects.values().next().value;
33-
} else {
34-
const projectNames = Array.from(workspace.projects.keys());
35-
throw new SchematicsException(
36-
`Multiple projects found: [${projectNames.join(', ')}]. Please specify a project name.`,
37-
);
38-
}
33+
34+
return { project, name: projectName };
3935
}
4036

41-
if (!project) {
42-
// This case should theoretically not be hit due to the checks above, but it's good for type safety.
43-
throw new SchematicsException('Could not determine a project.');
37+
if (workspace.projects.size === 1) {
38+
const [name, project] = Array.from(workspace.projects.entries())[0];
39+
40+
return { project, name };
4441
}
4542

46-
return project.root;
43+
const projectNames = Array.from(workspace.projects.keys());
44+
throw new SchematicsException(
45+
`Multiple projects found: [${projectNames.join(', ')}]. Please specify a project name.`,
46+
);
4747
}
4848

4949
const DIRECTORIES_TO_SKIP = new Set(['node_modules', '.git', 'dist', '.angular']);
@@ -74,14 +74,45 @@ function findTestFiles(directory: DirEntry, fileSuffix: string): string[] {
7474
export default function (options: Schema): Rule {
7575
return async (tree: Tree, context: SchematicContext) => {
7676
const reporter = new RefactorReporter(context.logger);
77-
const projectRoot = await getProjectRoot(tree, options.project);
77+
const { project, name: projectName } = await getProject(tree, options.project);
78+
const projectRoot = project.root;
7879
const fileSuffix = options.fileSuffix ?? '.spec.ts';
7980

80-
const files = findTestFiles(tree.getDir(projectRoot), fileSuffix);
81+
let files: string[];
82+
let searchScope: string;
83+
84+
if (options.include) {
85+
const normalizedInclude = options.include.replace(/\\/g, '/');
86+
const includePath = normalize(join(projectRoot, normalizedInclude));
87+
searchScope = options.include;
88+
89+
let dirEntry: DirEntry | null = null;
90+
try {
91+
dirEntry = tree.getDir(includePath);
92+
} catch {
93+
// Path is not a directory.
94+
}
95+
96+
// Approximation of a directory exists check
97+
if (dirEntry && (dirEntry.subdirs.length > 0 || dirEntry.subfiles.length > 0)) {
98+
// It is a directory
99+
files = findTestFiles(dirEntry, fileSuffix);
100+
} else if (tree.exists(includePath)) {
101+
// It is a file
102+
files = [includePath];
103+
} else {
104+
throw new SchematicsException(
105+
`The specified include path '${options.include}' does not exist.`,
106+
);
107+
}
108+
} else {
109+
searchScope = `project '${projectName}'`;
110+
files = findTestFiles(tree.getDir(projectRoot), fileSuffix);
111+
}
81112

82113
if (files.length === 0) {
83114
throw new SchematicsException(
84-
`No files ending with '${fileSuffix}' found in project '${options.project}'.`,
115+
`No files ending with '${fileSuffix}' found in ${searchScope}.`,
85116
);
86117
}
87118

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

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,94 @@ describe('Jasmine to Vitest Schematic', () => {
123123
expect(logs.some((log) => log.includes('Transformed `spyOn` to `vi.spyOn`'))).toBe(true);
124124
});
125125

126+
describe('with `include` option', () => {
127+
beforeEach(() => {
128+
// Create a nested structure for testing directory-specific inclusion
129+
appTree.create(
130+
'projects/bar/src/app/nested/nested.spec.ts',
131+
`describe('Nested', () => { it('should work', () => { spyOn(window, 'confirm'); }); });`,
132+
);
133+
appTree.overwrite(
134+
'projects/bar/src/app/app.spec.ts',
135+
`describe('App', () => { it('should work', () => { spyOn(window, 'alert'); }); });`,
136+
);
137+
});
138+
139+
it('should only transform the specified file', async () => {
140+
const tree = await schematicRunner.runSchematic(
141+
'jasmine-to-vitest',
142+
{ project: 'bar', include: 'src/app/nested/nested.spec.ts' },
143+
appTree,
144+
);
145+
146+
const changedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts');
147+
expect(changedContent).toContain(`vi.spyOn(window, 'confirm');`);
148+
149+
const unchangedContent = tree.readContent('projects/bar/src/app/app.spec.ts');
150+
expect(unchangedContent).toContain(`spyOn(window, 'alert');`);
151+
});
152+
153+
it('should handle a Windows-style path', async () => {
154+
const tree = await schematicRunner.runSchematic(
155+
'jasmine-to-vitest',
156+
{ project: 'bar', include: 'src\\app\\nested\\nested.spec.ts' },
157+
appTree,
158+
);
159+
160+
const changedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts');
161+
expect(changedContent).toContain(`vi.spyOn(window, 'confirm');`);
162+
163+
const unchangedContent = tree.readContent('projects/bar/src/app/app.spec.ts');
164+
expect(unchangedContent).toContain(`spyOn(window, 'alert');`);
165+
});
166+
167+
it('should only transform files in the specified directory', async () => {
168+
appTree.create(
169+
'projects/bar/src/other/other.spec.ts',
170+
`describe('Other', () => { it('should work', () => { spyOn(window, 'close'); }); });`,
171+
);
172+
173+
const tree = await schematicRunner.runSchematic(
174+
'jasmine-to-vitest',
175+
{ project: 'bar', include: 'src/app' },
176+
appTree,
177+
);
178+
179+
const changedAppContent = tree.readContent('projects/bar/src/app/app.spec.ts');
180+
expect(changedAppContent).toContain(`vi.spyOn(window, 'alert');`);
181+
182+
const changedNestedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts');
183+
expect(changedNestedContent).toContain(`vi.spyOn(window, 'confirm');`);
184+
185+
const unchangedContent = tree.readContent('projects/bar/src/other/other.spec.ts');
186+
expect(unchangedContent).toContain(`spyOn(window, 'close');`);
187+
});
188+
189+
it('should process all files if `include` is not provided', async () => {
190+
const tree = await schematicRunner.runSchematic(
191+
'jasmine-to-vitest',
192+
{ project: 'bar' },
193+
appTree,
194+
);
195+
196+
const changedAppContent = tree.readContent('projects/bar/src/app/app.spec.ts');
197+
expect(changedAppContent).toContain(`vi.spyOn(window, 'alert');`);
198+
199+
const changedNestedContent = tree.readContent('projects/bar/src/app/nested/nested.spec.ts');
200+
expect(changedNestedContent).toContain(`vi.spyOn(window, 'confirm');`);
201+
});
202+
203+
it('should throw if the include path does not exist', async () => {
204+
await expectAsync(
205+
schematicRunner.runSchematic(
206+
'jasmine-to-vitest',
207+
{ project: 'bar', include: 'src/non-existent' },
208+
appTree,
209+
),
210+
).toBeRejectedWithError(`The specified include path 'src/non-existent' does not exist.`);
211+
});
212+
});
213+
126214
it('should print a summary report after running', async () => {
127215
const specFilePath = 'projects/bar/src/app/app.spec.ts';
128216
const content = `
@@ -138,7 +226,7 @@ describe('Jasmine to Vitest Schematic', () => {
138226
const logs: string[] = [];
139227
schematicRunner.logger.subscribe((entry) => logs.push(entry.message));
140228

141-
await schematicRunner.runSchematic('jasmine-to-vitest', { include: specFilePath }, appTree);
229+
await schematicRunner.runSchematic('jasmine-to-vitest', {}, appTree);
142230

143231
expect(logs).toContain('Jasmine to Vitest Refactoring Summary:');
144232
expect(logs).toContain('- 1 test file(s) scanned.');

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
"type": "object",
66
"description": "Refactors a Jasmine test file to use Vitest.",
77
"properties": {
8+
"include": {
9+
"type": "string",
10+
"description": "A path to a specific file or directory to refactor. If not provided, all test files in the project will be refactored."
11+
},
812
"fileSuffix": {
913
"type": "string",
1014
"description": "The file suffix to identify test files (e.g., '.spec.ts', '.test.ts').",

0 commit comments

Comments
 (0)