Skip to content

Commit e2ee686

Browse files
committed
fixup! fix(@schematics/angular): migrate CallInfo returnValue and object in refactor-jasmine-vitest
1 parent 4134fc5 commit e2ee686

2 files changed

Lines changed: 131 additions & 19 deletions

File tree

‎packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy.ts‎

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,52 @@ import { addTodoComment } from '../utils/comment-helpers';
2525
import { RefactorContext } from '../utils/refactor-context';
2626
import { 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+
2874
function 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
*/
541581
function 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) {

‎packages/schematics/angular/refactor/jasmine-vitest/transformers/jasmine-spy_spec.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,61 @@ describe('transformSpyCallInspection', () => {
362362
input: `expect(mySpy.calls.all()[1].object).toBe(service);`,
363363
expected: `expect(vi.mocked(mySpy).mock.contexts[1]).toBe(service);`,
364364
},
365+
{
366+
description: 'should transform CallInfo properties with non-null assertions',
367+
input: `
368+
const recentArgs = mySpy.calls.mostRecent()!.args;
369+
const firstResult = mySpy.calls.first()!.returnValue;
370+
const indexedThis = mySpy.calls.all()[1]!.object;
371+
`,
372+
expected: `
373+
const recentArgs = vi.mocked(mySpy).mock.lastCall;
374+
const firstResult = vi.mocked(mySpy).mock.results[0].value;
375+
const indexedThis = vi.mocked(mySpy).mock.contexts[1];
376+
`,
377+
},
378+
{
379+
description: 'should transform CallInfo properties with parenthesized expressions',
380+
input: `const firstResult = (mySpy.calls.first()).returnValue;`,
381+
expected: `const firstResult = vi.mocked(mySpy).mock.results[0].value;`,
382+
},
383+
{
384+
description: 'should transform CallInfo properties with type assertions',
385+
input: `
386+
const recentArgs = (mySpy.calls.mostRecent() as jasmine.CallInfo).args;
387+
const firstResult = (<jasmine.CallInfo>mySpy.calls.first()).returnValue;
388+
const indexedThis = (mySpy.calls.all()[1] satisfies jasmine.CallInfo).object;
389+
`,
390+
expected: `
391+
const recentArgs = vi.mocked(mySpy).mock.lastCall;
392+
const firstResult = vi.mocked(mySpy).mock.results[0].value;
393+
const indexedThis = vi.mocked(mySpy).mock.contexts[1];
394+
`,
395+
},
396+
{
397+
description: 'should transform spy.calls.all()[i] properties when the all() call is wrapped',
398+
input: `
399+
const parenthesizedArgs = (mySpy.calls.all())[1].args;
400+
const assertedArgs = mySpy.calls.all()![1].args;
401+
`,
402+
expected: `
403+
const parenthesizedArgs = vi.mocked(mySpy).mock.calls[1];
404+
const assertedArgs = vi.mocked(mySpy).mock.calls[1];
405+
`,
406+
},
407+
{
408+
description: 'should preserve optional chaining on CallInfo returnValue accesses',
409+
input: `
410+
const firstResult = mySpy.calls.first()?.returnValue;
411+
const indexedResult = mySpy.calls.all()[1]?.returnValue;
412+
const recentResult = mySpy.calls.mostRecent()?.returnValue;
413+
`,
414+
expected: `
415+
const firstResult = vi.mocked(mySpy).mock.results[0]?.value;
416+
const indexedResult = vi.mocked(mySpy).mock.results[1]?.value;
417+
const recentResult = vi.mocked(mySpy).mock.results.at(-1)?.value;
418+
`,
419+
},
365420
{
366421
description: 'should add a TODO for spy.calls.mostRecent() without .args',
367422
input: `const mostRecent = mySpy.calls.mostRecent();`,

0 commit comments

Comments
 (0)