@@ -25,6 +25,52 @@ import { addTodoComment } from '../utils/comment-helpers';
2525import { RefactorContext } from '../utils/refactor-context' ;
2626import { createViCallExpression } from '../utils/refactor-helpers' ;
2727
28+ /**
29+ * Expression wrappers that do not affect the runtime value of the wrapped expression and
30+ * can be looked through when matching Jasmine spy patterns.
31+ */
32+ type TransparentWrapper =
33+ | ts . ParenthesizedExpression
34+ | ts . AsExpression
35+ | ts . NonNullExpression
36+ | ts . TypeAssertion
37+ | ts . SatisfiesExpression ;
38+
39+ function isTransparentWrapper ( node : ts . Node ) : node is TransparentWrapper {
40+ return (
41+ ts . isParenthesizedExpression ( node ) ||
42+ ts . isAsExpression ( node ) ||
43+ ts . isNonNullExpression ( node ) ||
44+ ts . isTypeAssertionExpression ( node ) ||
45+ ts . isSatisfiesExpression ( node )
46+ ) ;
47+ }
48+
49+ /**
50+ * Returns the innermost expression after removing any transparent wrappers
51+ * (parentheses, non-null assertions, and type assertions).
52+ */
53+ function unwrapExpression ( expression : ts . Expression ) : ts . Expression {
54+ let current = expression ;
55+ while ( isTransparentWrapper ( current ) ) {
56+ current = current . expression ;
57+ }
58+
59+ return current ;
60+ }
61+
62+ /**
63+ * Returns the nearest ancestor of a node that is not a transparent wrapper.
64+ */
65+ function getUnwrappedParent ( node : ts . Node ) : ts . Node | undefined {
66+ let parent : ts . Node | undefined = node . parent ;
67+ while ( parent && isTransparentWrapper ( parent ) ) {
68+ parent = parent . parent ;
69+ }
70+
71+ return parent ;
72+ }
73+
2874function isChainedWithAnd ( node : ts . Node ) : boolean {
2975 let parent = node . parent ;
3076 while ( parent ) {
@@ -39,13 +85,7 @@ function isChainedWithAnd(node: ts.Node): boolean {
3985 ) {
4086 return true ;
4187 }
42- } else if (
43- ts . isParenthesizedExpression ( parent ) ||
44- ts . isAsExpression ( parent ) ||
45- ts . isNonNullExpression ( parent ) ||
46- ts . isTypeAssertionExpression ( parent ) ||
47- ts . isSatisfiesExpression ( parent )
48- ) {
88+ } else if ( isTransparentWrapper ( parent ) ) {
4989 parent = parent . parent ;
5090 continue ;
5191 }
@@ -539,11 +579,12 @@ interface CallInfoAccess {
539579 * `spy.calls.first()`, `spy.calls.mostRecent()`, or `spy.calls.all()[i]`.
540580 */
541581function getCallInfoAccess ( expression : ts . Expression ) : CallInfoAccess | undefined {
582+ const unwrapped = unwrapExpression ( expression ) ;
542583 let indexExpression : ts . Expression | undefined ;
543- let callExpression : ts . Expression = expression ;
544- if ( ts . isElementAccessExpression ( expression ) ) {
545- indexExpression = expression . argumentExpression ;
546- callExpression = expression . expression ;
584+ let callExpression : ts . Expression = unwrapped ;
585+ if ( ts . isElementAccessExpression ( unwrapped ) ) {
586+ indexExpression = unwrapped . argumentExpression ;
587+ callExpression = unwrapExpression ( unwrapped . expression ) ;
547588 }
548589
549590 if (
@@ -625,6 +666,13 @@ function transformCallInfoProperty(
625666 ) ;
626667 const mockCollection = createPropertyAccess ( mockProperty , mapping . mockProperty ) ;
627668
669+ // Note: for `args` and `object`, an optional access (`?.`) in the source is intentionally
670+ // not carried over. The replacement is a plain element access (e.g. `mock.calls[0]`), which
671+ // already evaluates to `undefined` when the spy has not been called, so the result of the
672+ // transformed expression itself is unchanged. Only `returnValue` has a trailing `.value`
673+ // access that must remain optional to avoid throwing on a missing call.
674+ const accessOperator = node . questionDotToken ? '?.' : '.' ;
675+
628676 let replacement : ts . Expression ;
629677 let replacementText : string ;
630678 if ( callInfo . index === 'last' ) {
@@ -634,7 +682,7 @@ function transformCallInfoProperty(
634682 replacementText = 'vi.mocked(spy).mock.lastCall' ;
635683 } else {
636684 // `mock.<collection>.at(-1)`; the result may be `undefined` if the spy was never called,
637- // so a trailing value access must be optional.
685+ // so a trailing value access must always be optional.
638686 replacement = ts . factory . createCallExpression (
639687 createPropertyAccess ( mockCollection , 'at' ) ,
640688 undefined ,
@@ -659,15 +707,21 @@ function transformCallInfoProperty(
659707 replacement = ts . factory . createElementAccessExpression ( mockCollection , callInfo . index ) ;
660708 replacementText = `vi.mocked(spy).mock.${ mapping . mockProperty } [${ callInfo . indexText } ]` ;
661709 if ( mapping . valueAccess ) {
662- replacement = createPropertyAccess ( replacement , mapping . valueAccess ) ;
663- replacementText += `.${ mapping . valueAccess } ` ;
710+ replacement = node . questionDotToken
711+ ? ts . factory . createPropertyAccessChain (
712+ replacement ,
713+ ts . factory . createToken ( ts . SyntaxKind . QuestionDotToken ) ,
714+ mapping . valueAccess ,
715+ )
716+ : createPropertyAccess ( replacement , mapping . valueAccess ) ;
717+ replacementText += `${ accessOperator } ${ mapping . valueAccess } ` ;
664718 }
665719 }
666720
667721 reporter . reportTransformation (
668722 sourceFile ,
669723 node ,
670- `Transformed \`${ callInfo . sourceText } . ${ propertyName } \` to \`${ replacementText } \`.` ,
724+ `Transformed \`${ callInfo . sourceText } ${ accessOperator } ${ propertyName } \` to \`${ replacementText } \`.` ,
671725 ) ;
672726
673727 return replacement ;
@@ -775,20 +829,23 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC
775829 addTodoComment ( node , category ) ;
776830 }
777831 break ;
778- case 'mostRecent' :
832+ case 'mostRecent' : {
779833 // Accesses such as `.args`, `.returnValue`, and `.object` are handled by
780834 // `transformCallInfoProperty`; only a bare `mostRecent()` requires manual migration.
835+ const parent = getUnwrappedParent ( node ) ;
781836 if (
782- ! ts . isPropertyAccessExpression ( node . parent ) ||
783- ! ts . isIdentifier ( node . parent . name ) ||
784- ! CALL_INFO_PROPERTIES . has ( node . parent . name . text )
837+ ! parent ||
838+ ! ts . isPropertyAccessExpression ( parent ) ||
839+ ! ts . isIdentifier ( parent . name ) ||
840+ ! CALL_INFO_PROPERTIES . has ( parent . name . text )
785841 ) {
786842 const category = 'mostRecent-without-args' ;
787843 reporter . recordTodo ( category , sourceFile , node ) ;
788844 addTodoComment ( node , category ) ;
789845 }
790846
791847 return node ;
848+ }
792849 }
793850
794851 if ( newExpression && message ) {
0 commit comments