From dac3d3f3b0da391d4b58b55a8c738afde522a24f 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 2498855c4607..0e2f2e35b9e7 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/index.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/index.ts @@ -130,6 +130,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 cb361280e473..4192a27367fd 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/schema.json +++ b/packages/schematics/angular/refactor/jasmine-vitest/schema.json @@ -35,6 +35,11 @@ "type": "boolean", "description": "Whether the tests are intended to run in browser mode. If true, the `toHaveClass` assertions are left as is because Vitest browser mode has such an assertion. Otherwise they're migrated to an equivalent assertion.", "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 058d015c9773..2872a3f7503e 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-misc.ts @@ -154,7 +154,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 }); } @@ -187,7 +187,7 @@ export function transformUnsupportedJasmineCalls( node, `Found unsupported call \`jasmine.${methodName}\`.`, ); - reporter.recordTodo(methodName); + reporter.recordTodo(methodName, sourceFile, node); addTodoComment(node, methodName); } @@ -238,7 +238,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 39c5802ea1dc..f019dbb53099 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; @@ -236,7 +236,7 @@ export function transformCreateSpyObj( if (node.arguments.length < 2 && hasBaseName) { const category = 'createSpyObj-single-argument'; - reporter.recordTodo(category); + reporter.recordTodo(category, sourceFile, node); addTodoComment(node, category); return node; @@ -248,7 +248,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; @@ -259,7 +259,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); } } @@ -486,7 +486,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'); + }); });