Skip to content

Commit 4134fc5

Browse files
committed
fix(@schematics/angular): migrate CallInfo returnValue and object in refactor-jasmine-vitest
Previously, only the `.args` property of a Jasmine `CallInfo` object obtained via `spy.calls.first()`, `spy.calls.mostRecent()`, or `spy.calls.all()[i]` was migrated. Accessing `.returnValue` or `.object` on these results left the property intact on the transformed `mock.calls[i]` tuple, producing code that fails type checking and throws at runtime. These properties are now mapped to the corresponding Vitest `MockContext` members: - `spy.calls.first().returnValue` -> `vi.mocked(spy).mock.results[0].value` - `spy.calls.all()[i].returnValue` -> `vi.mocked(spy).mock.results[i].value` - `spy.calls.mostRecent().returnValue` -> `vi.mocked(spy).mock.results.at(-1)?.value` - `spy.calls.first().object` -> `vi.mocked(spy).mock.contexts[0]` - `spy.calls.all()[i].object` -> `vi.mocked(spy).mock.contexts[i]` - `spy.calls.mostRecent().object` -> `vi.mocked(spy).mock.contexts.at(-1)` The separate `transformMostRecentArgs` and `transformAllCallsArgs` handlers have been consolidated into a single `transformCallInfoProperty` transformer, and the TODO for a bare `mostRecent()` call no longer fires when one of the supported properties is accessed.
1 parent f15c8c6 commit 4134fc5

3 files changed

Lines changed: 186 additions & 97 deletions

File tree

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

Lines changed: 153 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* @fileoverview This file contains transformers dedicated to converting Jasmine's spying
1111
* functionality to Vitest's mocking APIs. It handles the creation of spies (`spyOn`,
1212
* `createSpy`, `createSpyObj`), spy strategies (`and.returnValue`, `and.callFake`),
13-
* and the inspection of spy calls (`spy.calls.reset`, `spy.calls.mostRecent`).
13+
* and the inspection of spy calls (`spy.calls.reset`, `spy.calls.first`, `spy.calls.mostRecent`,
14+
* `spy.calls.all`, and the `args`, `returnValue`, and `object` properties of their results).
1415
*/
1516

1617
import ts from 'typescript';
@@ -505,64 +506,171 @@ function createMockedSpyMockProperty(
505506
return createPropertyAccess(mockedSpy, 'mock');
506507
}
507508

508-
function transformMostRecentArgs(
509-
node: ts.Node,
510-
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
511-
): ts.Node {
512-
// Check 1: Is it a property access for `.args`?
513-
if (
514-
!ts.isPropertyAccessExpression(node) ||
515-
!ts.isIdentifier(node.name) ||
516-
node.name.text !== 'args'
517-
) {
518-
return node;
509+
/**
510+
* Maps the properties of a Jasmine `CallInfo` object to the corresponding property on
511+
* Vitest's `MockContext`. The optional `valueAccess` is a trailing property that must be
512+
* accessed on the resulting element (e.g. `mock.results[i].value`).
513+
*/
514+
const CALL_INFO_PROPERTIES = new Map<string, { mockProperty: string; valueAccess?: string }>([
515+
['args', { mockProperty: 'calls' }],
516+
['returnValue', { mockProperty: 'results', valueAccess: 'value' }],
517+
['object', { mockProperty: 'contexts' }],
518+
]);
519+
520+
/**
521+
* Describes a matched expression that evaluates to a Jasmine `CallInfo` object.
522+
*/
523+
interface CallInfoAccess {
524+
/** The expression representing the spy (e.g. `mySpy` in `mySpy.calls.first()`). */
525+
spyIdentifier: ts.Expression;
526+
527+
/** The index of the call, or `'last'` when referring to the most recent call. */
528+
index: ts.Expression | 'last';
529+
530+
/** A human-readable representation of the matched Jasmine source, for reporting. */
531+
sourceText: string;
532+
533+
/** A human-readable representation of the index used in the replacement, for reporting. */
534+
indexText: string;
535+
}
536+
537+
/**
538+
* Attempts to match an expression that yields a Jasmine `CallInfo` object:
539+
* `spy.calls.first()`, `spy.calls.mostRecent()`, or `spy.calls.all()[i]`.
540+
*/
541+
function getCallInfoAccess(expression: ts.Expression): CallInfoAccess | undefined {
542+
let indexExpression: ts.Expression | undefined;
543+
let callExpression: ts.Expression = expression;
544+
if (ts.isElementAccessExpression(expression)) {
545+
indexExpression = expression.argumentExpression;
546+
callExpression = expression.expression;
519547
}
520548

521-
// Check 2: Is the preceding expression a call expression?
522-
const mostRecentCall = node.expression;
523549
if (
524-
!ts.isCallExpression(mostRecentCall) ||
525-
!ts.isPropertyAccessExpression(mostRecentCall.expression)
550+
!ts.isCallExpression(callExpression) ||
551+
!ts.isPropertyAccessExpression(callExpression.expression)
526552
) {
527-
return node;
553+
return undefined;
528554
}
529555

530-
// Check 3: Is it a call to `.mostRecent` or `.first`?
531-
const mostRecentPae = mostRecentCall.expression;
532-
if (
533-
!ts.isIdentifier(mostRecentPae.name) ||
534-
(mostRecentPae.name.text !== 'mostRecent' && mostRecentPae.name.text !== 'first') ||
535-
!ts.isPropertyAccessExpression(mostRecentPae.expression)
536-
) {
537-
return node;
556+
const methodPae = callExpression.expression;
557+
if (!ts.isIdentifier(methodPae.name) || !ts.isPropertyAccessExpression(methodPae.expression)) {
558+
return undefined;
538559
}
539560

540-
// Check 4: Can we get the spy identifier from `spy.calls`?
541-
const spyIdentifier = getSpyIdentifierFromCalls(mostRecentPae.expression);
561+
const spyIdentifier = getSpyIdentifierFromCalls(methodPae.expression);
542562
if (!spyIdentifier) {
563+
return undefined;
564+
}
565+
566+
const methodName = methodPae.name.text;
567+
if (indexExpression) {
568+
// Only `spy.calls.all()[i]` is supported when an element access is present.
569+
return methodName === 'all'
570+
? { spyIdentifier, index: indexExpression, sourceText: 'spy.calls.all()[i]', indexText: 'i' }
571+
: undefined;
572+
}
573+
574+
switch (methodName) {
575+
case 'first':
576+
return {
577+
spyIdentifier,
578+
index: ts.factory.createNumericLiteral(0),
579+
sourceText: 'spy.calls.first()',
580+
indexText: '0',
581+
};
582+
case 'mostRecent':
583+
return { spyIdentifier, index: 'last', sourceText: 'spy.calls.mostRecent()', indexText: '' };
584+
}
585+
586+
return undefined;
587+
}
588+
589+
/**
590+
* Transforms property accesses on a Jasmine `CallInfo` object (`.args`, `.returnValue`,
591+
* `.object`) obtained via `spy.calls.first()`, `spy.calls.mostRecent()`, or `spy.calls.all()[i]`
592+
* into the equivalent Vitest `MockContext` access.
593+
*
594+
* @example
595+
* spy.calls.first().args -> vi.mocked(spy).mock.calls[0]
596+
* spy.calls.mostRecent().args -> vi.mocked(spy).mock.lastCall
597+
* spy.calls.all()[i].args -> vi.mocked(spy).mock.calls[i]
598+
* spy.calls.first().returnValue -> vi.mocked(spy).mock.results[0].value
599+
* spy.calls.mostRecent().returnValue -> vi.mocked(spy).mock.results.at(-1)?.value
600+
* spy.calls.all()[i].object -> vi.mocked(spy).mock.contexts[i]
601+
* spy.calls.mostRecent().object -> vi.mocked(spy).mock.contexts.at(-1)
602+
*/
603+
function transformCallInfoProperty(
604+
node: ts.Node,
605+
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
606+
): ts.Node {
607+
if (!ts.isPropertyAccessExpression(node) || !ts.isIdentifier(node.name)) {
543608
return node;
544609
}
545610

546-
if (mostRecentPae.name.text === 'first') {
547-
reporter.reportTransformation(
548-
sourceFile,
549-
node,
550-
'Transformed `spy.calls.first().args` to `vi.mocked(spy).mock.calls[0]`.',
551-
);
552-
const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports);
611+
const propertyName = node.name.text;
612+
const mapping = CALL_INFO_PROPERTIES.get(propertyName);
613+
if (!mapping) {
614+
return node;
615+
}
553616

554-
return ts.factory.createElementAccessExpression(createPropertyAccess(mockProperty, 'calls'), 0);
617+
const callInfo = getCallInfoAccess(node.expression);
618+
if (!callInfo) {
619+
return node;
620+
}
621+
622+
const mockProperty = createMockedSpyMockProperty(
623+
callInfo.spyIdentifier,
624+
pendingVitestValueImports,
625+
);
626+
const mockCollection = createPropertyAccess(mockProperty, mapping.mockProperty);
627+
628+
let replacement: ts.Expression;
629+
let replacementText: string;
630+
if (callInfo.index === 'last') {
631+
if (propertyName === 'args') {
632+
// Vitest provides a dedicated shorthand for the arguments of the last call.
633+
replacement = createPropertyAccess(mockProperty, 'lastCall');
634+
replacementText = 'vi.mocked(spy).mock.lastCall';
635+
} else {
636+
// `mock.<collection>.at(-1)`; the result may be `undefined` if the spy was never called,
637+
// so a trailing value access must be optional.
638+
replacement = ts.factory.createCallExpression(
639+
createPropertyAccess(mockCollection, 'at'),
640+
undefined,
641+
[
642+
ts.factory.createPrefixUnaryExpression(
643+
ts.SyntaxKind.MinusToken,
644+
ts.factory.createNumericLiteral(1),
645+
),
646+
],
647+
);
648+
replacementText = `vi.mocked(spy).mock.${mapping.mockProperty}.at(-1)`;
649+
if (mapping.valueAccess) {
650+
replacement = ts.factory.createPropertyAccessChain(
651+
replacement,
652+
ts.factory.createToken(ts.SyntaxKind.QuestionDotToken),
653+
mapping.valueAccess,
654+
);
655+
replacementText += `?.${mapping.valueAccess}`;
656+
}
657+
}
658+
} else {
659+
replacement = ts.factory.createElementAccessExpression(mockCollection, callInfo.index);
660+
replacementText = `vi.mocked(spy).mock.${mapping.mockProperty}[${callInfo.indexText}]`;
661+
if (mapping.valueAccess) {
662+
replacement = createPropertyAccess(replacement, mapping.valueAccess);
663+
replacementText += `.${mapping.valueAccess}`;
664+
}
555665
}
556666

557-
// If all checks pass, perform the transformation.
558667
reporter.reportTransformation(
559668
sourceFile,
560669
node,
561-
'Transformed `spy.calls.mostRecent().args` to `vi.mocked(spy).mock.lastCall`.',
670+
`Transformed \`${callInfo.sourceText}.${propertyName}\` to \`${replacementText}\`.`,
562671
);
563-
const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports);
564672

565-
return createPropertyAccess(mockProperty, 'lastCall');
673+
return replacement;
566674
}
567675

568676
function transformThisFor(
@@ -604,69 +712,17 @@ function transformThisFor(
604712
);
605713
}
606714

607-
function transformAllCallsArgs(
608-
node: ts.Node,
609-
{ sourceFile, reporter, pendingVitestValueImports }: RefactorContext,
610-
): ts.Node {
611-
if (
612-
!ts.isPropertyAccessExpression(node) ||
613-
!ts.isIdentifier(node.name) ||
614-
node.name.text !== 'args'
615-
) {
616-
return node;
617-
}
618-
619-
const elementAccess = node.expression;
620-
if (!ts.isElementAccessExpression(elementAccess)) {
621-
return node;
622-
}
623-
624-
const allCall = elementAccess.expression;
625-
if (!ts.isCallExpression(allCall) || !ts.isPropertyAccessExpression(allCall.expression)) {
626-
return node;
627-
}
628-
629-
const allPae = allCall.expression;
630-
if (!ts.isIdentifier(allPae.name) || allPae.name.text !== 'all') {
631-
return node;
632-
}
633-
634-
if (!ts.isPropertyAccessExpression(allPae.expression)) {
635-
return node;
636-
}
637-
638-
const spyIdentifier = getSpyIdentifierFromCalls(allPae.expression);
639-
if (!spyIdentifier) {
640-
return node;
641-
}
642-
643-
reporter.reportTransformation(
644-
sourceFile,
645-
node,
646-
'Transformed `spy.calls.all()[i].args` to `vi.mocked(spy).mock.calls[i]`.',
647-
);
648-
const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports);
649-
const callsProperty = createPropertyAccess(mockProperty, 'calls');
650-
651-
return ts.factory.createElementAccessExpression(callsProperty, elementAccess.argumentExpression);
652-
}
653-
654715
export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorContext): ts.Node {
655-
const mostRecentArgsTransformed = transformMostRecentArgs(node, refactorCtx);
656-
if (mostRecentArgsTransformed !== node) {
657-
return mostRecentArgsTransformed;
716+
const callInfoPropertyTransformed = transformCallInfoProperty(node, refactorCtx);
717+
if (callInfoPropertyTransformed !== node) {
718+
return callInfoPropertyTransformed;
658719
}
659720

660721
const thisForTransformed = transformThisFor(node, refactorCtx);
661722
if (thisForTransformed !== node) {
662723
return thisForTransformed;
663724
}
664725

665-
const allCallsArgsTransformed = transformAllCallsArgs(node, refactorCtx);
666-
if (allCallsArgsTransformed !== node) {
667-
return allCallsArgsTransformed;
668-
}
669-
670726
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) {
671727
return node;
672728
}
@@ -720,10 +776,12 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC
720776
}
721777
break;
722778
case 'mostRecent':
779+
// Accesses such as `.args`, `.returnValue`, and `.object` are handled by
780+
// `transformCallInfoProperty`; only a bare `mostRecent()` requires manual migration.
723781
if (
724782
!ts.isPropertyAccessExpression(node.parent) ||
725783
!ts.isIdentifier(node.parent.name) ||
726-
node.parent.name.text !== 'args'
784+
!CALL_INFO_PROPERTIES.has(node.parent.name.text)
727785
) {
728786
const category = 'mostRecent-without-args';
729787
reporter.recordTodo(category, sourceFile, node);

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,10 +332,40 @@ describe('transformSpyCallInspection', () => {
332332
input: `expect(mySpy.calls.first().args[0]).toBe(1);`,
333333
expected: `expect(vi.mocked(mySpy).mock.calls[0][0]).toBe(1);`,
334334
},
335+
{
336+
description: 'should transform spy.calls.first().returnValue',
337+
input: `const firstResult = mySpy.calls.first().returnValue;`,
338+
expected: `const firstResult = vi.mocked(mySpy).mock.results[0].value;`,
339+
},
340+
{
341+
description: 'should transform spy.calls.first().object',
342+
input: `const firstThis = mySpy.calls.first().object;`,
343+
expected: `const firstThis = vi.mocked(mySpy).mock.contexts[0];`,
344+
},
345+
{
346+
description: 'should transform spy.calls.mostRecent().returnValue',
347+
input: `const recentResult = mySpy.calls.mostRecent().returnValue;`,
348+
expected: `const recentResult = vi.mocked(mySpy).mock.results.at(-1)?.value;`,
349+
},
350+
{
351+
description: 'should transform spy.calls.mostRecent().object',
352+
input: `const recentThis = mySpy.calls.mostRecent().object;`,
353+
expected: `const recentThis = vi.mocked(mySpy).mock.contexts.at(-1);`,
354+
},
355+
{
356+
description: 'should transform spy.calls.all()[i].returnValue',
357+
input: `expect(mySpy.calls.all()[1].returnValue).toBe(42);`,
358+
expected: `expect(vi.mocked(mySpy).mock.results[1].value).toBe(42);`,
359+
},
360+
{
361+
description: 'should transform spy.calls.all()[i].object',
362+
input: `expect(mySpy.calls.all()[1].object).toBe(service);`,
363+
expected: `expect(vi.mocked(mySpy).mock.contexts[1]).toBe(service);`,
364+
},
335365
{
336366
description: 'should add a TODO for spy.calls.mostRecent() without .args',
337367
input: `const mostRecent = mySpy.calls.mostRecent();`,
338-
expected: `// TODO: vitest-migration: Direct usage of mostRecent() is not supported. Please refactor to access .args directly or use vi.mocked(spy).mock.lastCall. See: https://vitest.dev/api/mocked.html#mock-lastcall
368+
expected: `// TODO: vitest-migration: Direct usage of mostRecent() is not supported. Please refactor to access .args, .returnValue, or .object directly or use vi.mocked(spy).mock.lastCall. See: https://vitest.dev/api/mocked.html#mock-lastcall
339369
const mostRecent = mySpy.calls.mostRecent();`,
340370
},
341371
{

‎packages/schematics/angular/refactor/jasmine-vitest/utils/todo-notes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ export const TODO_NOTES = {
161161
'mostRecent-without-args': {
162162
message:
163163
'Direct usage of mostRecent() is not supported.' +
164-
' Please refactor to access .args directly or use vi.mocked(spy).mock.lastCall.',
164+
' Please refactor to access .args, .returnValue, or .object directly' +
165+
' or use vi.mocked(spy).mock.lastCall.',
165166
url: 'https://vitest.dev/api/mocked.html#mock-lastcall',
166167
},
167168
'saveArgumentsByValue': {

0 commit comments

Comments
 (0)