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 1e0a87dc80f2..454fdf8c1ce1 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher.ts @@ -259,24 +259,47 @@ export function transformExpectAsync( node: ts.Node, { sourceFile, reporter }: RefactorContext, ): ts.Node { - if ( - !ts.isCallExpression(node) || - !ts.isPropertyAccessExpression(node.expression) || - !ts.isCallExpression(node.expression.expression) - ) { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { return node; } const matcherCall = node; const matcherPae = node.expression; - const expectCall = node.expression.expression; - - if (!ts.isIdentifier(expectCall.expression) || expectCall.expression.text !== 'expectAsync') { + const matcherName = ts.isIdentifier(matcherPae.name) ? matcherPae.name.text : undefined; + if (!matcherName || matcherName === 'withContext') { return node; } - const matcherName = ts.isIdentifier(matcherPae.name) ? matcherPae.name.text : undefined; - const mapping = matcherName ? ASYNC_MATCHER_CHANGES.get(matcherName) : undefined; + // Walk down any modifier chain (`.not`, `.already`, `.withContext(...)`) to the root call. + // Modifiers have no direct vitest equivalent, so a modified matcher falls through to the + // TODO handling below. `withContext` is the only chainable modifier method, so any other + // intermediate call (e.g. a `.then(...)` chained on a matcher) is not an `expectAsync` chain. + const modifierNames: string[] = []; + let expectCall: ts.CallExpression | undefined; + let current: ts.Expression = matcherPae.expression; + while (!expectCall) { + if (ts.isCallExpression(current)) { + if (ts.isIdentifier(current.expression) && current.expression.text === 'expectAsync') { + expectCall = current; + } else if ( + ts.isPropertyAccessExpression(current.expression) && + ts.isIdentifier(current.expression.name) && + current.expression.name.text === 'withContext' + ) { + modifierNames.unshift('withContext'); + current = current.expression.expression; + } else { + return node; + } + } else if (ts.isPropertyAccessExpression(current) && ts.isIdentifier(current.name)) { + modifierNames.unshift(current.name.text); + current = current.expression; + } else { + return node; + } + } + + const mapping = modifierNames.length === 0 ? ASYNC_MATCHER_CHANGES.get(matcherName) : undefined; if (mapping) { reporter.reportTransformation( @@ -297,16 +320,14 @@ export function transformExpectAsync( return ts.factory.createCallExpression(newMatcherChain, undefined, newMatcherArgs); } - if (matcherName) { - if (matcherName === 'toBePending') { - const category = 'toBePending'; - reporter.recordTodo(category, sourceFile, node); - addTodoComment(node, category); - } else { - const category = 'unsupported-expect-async-matcher'; - reporter.recordTodo(category, sourceFile, node); - addTodoComment(node, category, { name: matcherName }); - } + if (matcherName === 'toBePending' && modifierNames.length === 0) { + const category = 'toBePending'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category); + } else { + const category = 'unsupported-expect-async-matcher'; + reporter.recordTodo(category, sourceFile, node); + addTodoComment(node, category, { name: [...modifierNames, matcherName].join('.') }); } return node; diff --git a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts index 274202ed1689..10c6be262414 100644 --- a/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts +++ b/packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-matcher_spec.ts @@ -84,6 +84,43 @@ describe('transformExpectAsync', () => { await expectAsync(myPromise).toBeSomethingElse(); `, }, + { + description: 'should add a TODO for a negated expectAsync matcher', + input: `await expectAsync(myPromise).not.toBeResolved();`, + expected: ` + // TODO: vitest-migration: Unsupported expectAsync matcher ".not.toBeResolved()" found. Please migrate this manually. + await expectAsync(myPromise).not.toBeResolved(); + `, + }, + { + description: 'should add a TODO for an expectAsync matcher modified with already', + input: `await expectAsync(myPromise).already.toBeResolved();`, + expected: ` + // TODO: vitest-migration: Unsupported expectAsync matcher ".already.toBeResolved()" found. Please migrate this manually. + await expectAsync(myPromise).already.toBeResolved(); + `, + }, + { + description: 'should add a TODO for an expectAsync matcher modified with withContext', + input: `await expectAsync(myPromise).withContext('msg').toBeResolved();`, + expected: ` + // TODO: vitest-migration: Unsupported expectAsync matcher ".withContext.toBeResolved()" found. Please migrate this manually. + await expectAsync(myPromise).withContext('msg').toBeResolved(); + `, + }, + { + description: 'should add a single TODO for an expectAsync matcher with combined modifiers', + input: `await expectAsync(myPromise).withContext('msg').not.toBeResolved();`, + expected: ` + // TODO: vitest-migration: Unsupported expectAsync matcher ".withContext.not.toBeResolved()" found. Please migrate this manually. + await expectAsync(myPromise).withContext('msg').not.toBeResolved(); + `, + }, + { + description: 'should transform an expectAsync matcher with a chained .then()', + input: `await expectAsync(myPromise).toBeResolved().then(() => {});`, + expected: `await expect(myPromise).resolves.not.toThrow().then(() => {});`, + }, { description: 'should add a specific TODO for toBePending', input: `await expectAsync(myPromise).toBePending();`,