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'); + }); });