From e7da10fb4bd7a9e357cc583ac24adfb22005bf55 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:20:09 -0500 Subject: [PATCH] feat(@schematics/angular): generate detailed migration report for `refactor-jasmine-vitest` The `refactor-jasmine-vitest` schematic will now generate a detailed migration report (`jasmine-vitest-.md`). This report provides a summary of the migration process and lists all files requiring manual attention (TODOs), organized by file path and line number. This helps developers quickly identify and address manual migration tasks in large codebases. The report generation is enabled by default but can be disabled via the `report` option. For example: ``` ng generate jasmine-to-vitest --no-report ``` --- .../angular/refactor/jasmine-vitest/index.ts | 5 + .../refactor/jasmine-vitest/schema.json | 5 + .../transformers/jasmine-lifecycle.ts | 4 +- .../transformers/jasmine-matcher.ts | 14 +-- .../transformers/jasmine-misc.ts | 6 +- .../transformers/jasmine-spy.ts | 12 +-- .../jasmine-vitest/utils/refactor-reporter.ts | 94 ++++++++++++++++++- .../utils/refactor-reporter_spec.ts | 32 ++++++- 8 files changed, 147 insertions(+), 25 deletions(-) diff --git a/packages/schematics/angular/refactor/jasmine-vitest/index.ts b/packages/schematics/angular/refactor/jasmine-vitest/index.ts index a7e34ad26c02..3353530c90d3 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -129,6 +129,11 @@ export default function (options: Schema): Rule { } } + if (options.report) { + const reportContent = reporter.generateReportContent(); + tree.create(`jasmine-vitest-${new Date().toISOString()}.md`, reportContent); + } + reporter.printSummary(options.verbose); }; } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/schema.json b/packages/schematics/angular/refactor/jasmine-vitest/schema.json index 99f34057ffb5..1bacae5ec1c6 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/schema.json +++ b/packages/schematics/angular/refactor/jasmine-vitest/schema.json @@ -30,6 +30,11 @@ "type": "boolean", "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.", "default": false + }, + "report": { + "type": "boolean", + "description": "Whether to generate a summary report file (jasmine-vitest-.md) in the project root.", + "default": true } } } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts index f2b60c35f327..9b0c61b6dca9 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-lifecycle.ts @@ -85,7 +85,7 @@ export function transformPending( 'Converted `pending()` to a skipped test (`it.skip`).', ); const category = 'pending'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, bodyNode); addTodoComment(replacement, category); ts.addSyntheticLeadingComment( replacement, @@ -412,7 +412,7 @@ export function transformDoneCallback(node: ts.Node, refactorCtx: RefactorContex `Found unhandled usage of \`${doneIdentifier.text}\` callback. Skipping transformation.`, ); const category = 'unhandled-done-usage'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts index 5de173bcd52b..05c137100271 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -56,7 +56,7 @@ export function transformSyntacticSugarMatchers( if (matcherName === 'toHaveSpyInteractions') { const category = 'toHaveSpyInteractions'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -64,7 +64,7 @@ export function transformSyntacticSugarMatchers( if (matcherName === 'toThrowMatching') { const category = 'toThrowMatching'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category, { name: matcherName }); return node; @@ -304,11 +304,11 @@ export function transformExpectAsync( if (matcherName) { if (matcherName === 'toBePending') { const category = 'toBePending'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); } else { const category = 'unsupported-expect-async-matcher'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category, { name: matcherName }); } } @@ -418,7 +418,7 @@ export function transformArrayWithExactContents( if (!ts.isArrayLiteralExpression(argument.arguments[0])) { const category = 'arrayWithExactContents-dynamic-variable'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -455,7 +455,7 @@ export function transformArrayWithExactContents( const containingStmt = ts.factory.createExpressionStatement(containingCall); const category = 'arrayWithExactContents-check'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(lengthStmt, category); return [lengthStmt, containingStmt]; @@ -615,7 +615,7 @@ export function transformExpectNothing( reporter.reportTransformation(sourceFile, node, 'Removed `expect().nothing()` statement.'); const category = 'expect-nothing'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(replacement, category); ts.addSyntheticLeadingComment( replacement, diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts index f9667701e376..f5500c9e3866 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -146,7 +146,7 @@ export function transformGlobalFunctions( `Found unsupported global function \`${functionName}\`.`, ); const category = 'unsupported-global-function'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category, { name: functionName }); } @@ -179,7 +179,7 @@ export function transformUnsupportedJasmineCalls( node, `Found unsupported call \`jasmine.${methodName}\`.`, ); - reporter.recordTodo(methodName); + reporter.recordTodo(methodName, sourceFile, node); addTodoComment(node, methodName); } @@ -230,7 +230,7 @@ export function transformUnknownJasmineProperties( `Found unknown jasmine property \`jasmine.${propName}\`.`, ); const category = 'unknown-jasmine-property'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category, { name: propName }); } } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts index c10fc739f9af..ad074c1a2bc0 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts @@ -153,7 +153,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. } default: { const category = 'unsupported-spy-strategy'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category, { name: strategyName }); break; } @@ -202,7 +202,7 @@ export function transformSpies(node: ts.Node, refactorCtx: RefactorContext): ts. 'Found unsupported `jasmine.spyOnAllFunctions()`.', ); const category = 'spyOnAllFunctions'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -235,7 +235,7 @@ export function transformCreateSpyObj( if (node.arguments.length < 2) { const category = 'createSpyObj-single-argument'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -247,7 +247,7 @@ export function transformCreateSpyObj( properties = createSpyObjWithObject(methods, baseName); } else { const category = 'createSpyObj-dynamic-variable'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -258,7 +258,7 @@ export function transformCreateSpyObj( properties.push(...(propertiesArg.properties as unknown as ts.PropertyAssignment[])); } else { const category = 'createSpyObj-dynamic-property-map'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); } } @@ -485,7 +485,7 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC node.parent.name.text !== 'args' ) { const category = 'mostRecent-without-args'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); } diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts index 737abdc0ef94..63b924d12139 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { logging } from '@angular-devkit/core'; import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { TodoCategory } from './todo-notes'; @@ -15,8 +14,9 @@ export class RefactorReporter { private filesTransformed = 0; private readonly todos = new Map(); private readonly verboseLogs = new Map(); + private readonly fileTodos = new Map(); - constructor(private logger: logging.LoggerApi) {} + constructor(private logger: { info(message: string): void; warn(message: string): void }) {} get hasTodos(): boolean { return this.todos.size > 0; @@ -30,14 +30,27 @@ export class RefactorReporter { this.filesTransformed++; } - recordTodo(category: TodoCategory): void { + recordTodo(category: TodoCategory, sourceFile: ts.SourceFile, node: ts.Node): void { this.todos.set(category, (this.todos.get(category) ?? 0) + 1); + + const { line } = ts.getLineAndCharacterOfPosition( + sourceFile, + ts.getOriginalNode(node).getStart(sourceFile), + ); + const filePath = sourceFile.fileName; + + let fileTodos = this.fileTodos.get(filePath); + if (!fileTodos) { + fileTodos = []; + this.fileTodos.set(filePath, fileTodos); + } + fileTodos.push({ category, line: line + 1 }); } reportTransformation(sourceFile: ts.SourceFile, node: ts.Node, message: string): void { const { line } = ts.getLineAndCharacterOfPosition( sourceFile, - ts.getOriginalNode(node).getStart(), + ts.getOriginalNode(node).getStart(sourceFile), ); const filePath = sourceFile.fileName; @@ -49,6 +62,79 @@ export class RefactorReporter { logs.push(`L${line + 1}: ${message}`); } + generateReportContent(): string { + const lines: string[] = []; + lines.push('# Jasmine to Vitest Refactoring Report'); + lines.push(''); + lines.push(`Date: ${new Date().toISOString()}`); + lines.push(''); + + const summaryEntries = [ + { label: 'Files Scanned', value: this.filesScanned }, + { label: 'Files Transformed', value: this.filesTransformed }, + { label: 'Files Skipped', value: this.filesScanned - this.filesTransformed }, + { label: 'Total TODOs', value: [...this.todos.values()].reduce((a, b) => a + b, 0) }, + ]; + + const firstColPad = Math.max(...summaryEntries.map(({ label }) => label.length)); + const secondColPad = 5; + + lines.push('## Summary'); + lines.push(''); + lines.push(`| ${' '.padEnd(firstColPad)} | ${'Count'.padStart(secondColPad)} |`); + lines.push(`|:${'-'.repeat(firstColPad + 1)}|${'-'.repeat(secondColPad + 1)}:|`); + for (const { label, value } of summaryEntries) { + lines.push(`| ${label.padEnd(firstColPad)} | ${String(value).padStart(secondColPad)} |`); + } + lines.push(''); + + if (this.todos.size > 0) { + lines.push('## TODO Overview'); + lines.push(''); + const todoEntries = [...this.todos.entries()]; + const firstColPad = Math.max( + 'Category'.length, + ...todoEntries.map(([category]) => category.length), + ); + const secondColPad = 5; + + lines.push(`| ${'Category'.padEnd(firstColPad)} | ${'Count'.padStart(secondColPad)} |`); + lines.push(`|:${'-'.repeat(firstColPad + 1)}|${'-'.repeat(secondColPad + 1)}:|`); + for (const [category, count] of todoEntries) { + lines.push(`| ${category.padEnd(firstColPad)} | ${String(count).padStart(secondColPad)} |`); + } + lines.push(''); + } + + if (this.fileTodos.size > 0) { + lines.push('## Files Requiring Manual Attention'); + lines.push(''); + // Sort files alphabetically + const sortedFiles = [...this.fileTodos.keys()].sort(); + + for (const filePath of sortedFiles) { + const relativePath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + lines.push(`### [\`${relativePath}\`](./${relativePath})`); + const todos = this.fileTodos.get(filePath); + if (todos) { + // Sort todos by line number + todos.sort((a, b) => a.line - b.line); + + for (const todo of todos) { + lines.push(`- [L${todo.line}](./${relativePath}#L${todo.line}): ${todo.category}`); + } + } + lines.push(''); + } + } else { + lines.push('## No Manual Changes Required'); + lines.push(''); + lines.push('All identified patterns were successfully transformed.'); + } + + return lines.join('\n'); + } + printSummary(verbose = false): void { if (verbose && this.verboseLogs.size > 0) { this.logger.info('Detailed Transformation Log:'); diff --git a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter_spec.ts index 10053135a10e..184dca31f9c7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/utils/refactor-reporter_spec.ts @@ -7,11 +7,14 @@ */ import { logging } from '@angular-devkit/core'; +import ts from '../../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { RefactorReporter } from './refactor-reporter'; describe('RefactorReporter', () => { let logger: logging.LoggerApi; let reporter: RefactorReporter; + let sourceFile: ts.SourceFile; + let node: ts.Node; beforeEach(() => { logger = { @@ -19,6 +22,8 @@ describe('RefactorReporter', () => { warn: jasmine.createSpy('warn'), } as unknown as logging.LoggerApi; reporter = new RefactorReporter(logger); + sourceFile = ts.createSourceFile('/test.spec.ts', 'statement;', ts.ScriptTarget.Latest); + node = sourceFile.statements[0]; }); it('should correctly increment scanned and transformed files', () => { @@ -34,9 +39,9 @@ describe('RefactorReporter', () => { }); it('should record and count todos by category', () => { - reporter.recordTodo('pending'); - reporter.recordTodo('spyOnAllFunctions'); - reporter.recordTodo('pending'); + reporter.recordTodo('pending', sourceFile, node); + reporter.recordTodo('spyOnAllFunctions', sourceFile, node); + reporter.recordTodo('pending', sourceFile, node); reporter.printSummary(); expect(logger.warn).toHaveBeenCalledWith('- 3 TODO(s) added for manual review:'); @@ -48,4 +53,25 @@ describe('RefactorReporter', () => { reporter.printSummary(); expect(logger.warn).not.toHaveBeenCalled(); }); + + it('should generate a markdown report with TODOs', () => { + reporter.incrementScannedFiles(); + reporter.recordTodo('pending', sourceFile, node); + + const report = reporter.generateReportContent(); + + expect(report).toContain('# Jasmine to Vitest Refactoring Report'); + expect(report).toContain('## Summary'); + expect(report).toContain('| | Count |'); + expect(report).toContain('|:------------------|------:|'); + expect(report).toContain('| Files Scanned | 1 |'); + expect(report).toContain('| Total TODOs | 1 |'); + expect(report).toContain('## TODO Overview'); + expect(report).toContain('| Category | Count |'); + expect(report).toContain('|:---------|------:|'); + expect(report).toContain('| pending | 1 |'); + expect(report).toContain('## Files Requiring Manual Attention'); + expect(report).toContain('### [`test.spec.ts`](./test.spec.ts)'); + expect(report).toContain('- [L1](./test.spec.ts#L1): pending'); + }); });