|
10 | 10 | * @fileoverview This file contains transformers dedicated to converting Jasmine's spying |
11 | 11 | * functionality to Vitest's mocking APIs. It handles the creation of spies (`spyOn`, |
12 | 12 | * `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). |
14 | 15 | */ |
15 | 16 |
|
16 | 17 | import ts from 'typescript'; |
@@ -505,64 +506,171 @@ function createMockedSpyMockProperty( |
505 | 506 | return createPropertyAccess(mockedSpy, 'mock'); |
506 | 507 | } |
507 | 508 |
|
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; |
519 | 547 | } |
520 | 548 |
|
521 | | - // Check 2: Is the preceding expression a call expression? |
522 | | - const mostRecentCall = node.expression; |
523 | 549 | if ( |
524 | | - !ts.isCallExpression(mostRecentCall) || |
525 | | - !ts.isPropertyAccessExpression(mostRecentCall.expression) |
| 550 | + !ts.isCallExpression(callExpression) || |
| 551 | + !ts.isPropertyAccessExpression(callExpression.expression) |
526 | 552 | ) { |
527 | | - return node; |
| 553 | + return undefined; |
528 | 554 | } |
529 | 555 |
|
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; |
538 | 559 | } |
539 | 560 |
|
540 | | - // Check 4: Can we get the spy identifier from `spy.calls`? |
541 | | - const spyIdentifier = getSpyIdentifierFromCalls(mostRecentPae.expression); |
| 561 | + const spyIdentifier = getSpyIdentifierFromCalls(methodPae.expression); |
542 | 562 | 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)) { |
543 | 608 | return node; |
544 | 609 | } |
545 | 610 |
|
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 | + } |
553 | 616 |
|
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 | + } |
555 | 665 | } |
556 | 666 |
|
557 | | - // If all checks pass, perform the transformation. |
558 | 667 | reporter.reportTransformation( |
559 | 668 | sourceFile, |
560 | 669 | node, |
561 | | - 'Transformed `spy.calls.mostRecent().args` to `vi.mocked(spy).mock.lastCall`.', |
| 670 | + `Transformed \`${callInfo.sourceText}.${propertyName}\` to \`${replacementText}\`.`, |
562 | 671 | ); |
563 | | - const mockProperty = createMockedSpyMockProperty(spyIdentifier, pendingVitestValueImports); |
564 | 672 |
|
565 | | - return createPropertyAccess(mockProperty, 'lastCall'); |
| 673 | + return replacement; |
566 | 674 | } |
567 | 675 |
|
568 | 676 | function transformThisFor( |
@@ -604,69 +712,17 @@ function transformThisFor( |
604 | 712 | ); |
605 | 713 | } |
606 | 714 |
|
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 | | - |
654 | 715 | 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; |
658 | 719 | } |
659 | 720 |
|
660 | 721 | const thisForTransformed = transformThisFor(node, refactorCtx); |
661 | 722 | if (thisForTransformed !== node) { |
662 | 723 | return thisForTransformed; |
663 | 724 | } |
664 | 725 |
|
665 | | - const allCallsArgsTransformed = transformAllCallsArgs(node, refactorCtx); |
666 | | - if (allCallsArgsTransformed !== node) { |
667 | | - return allCallsArgsTransformed; |
668 | | - } |
669 | | - |
670 | 726 | if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { |
671 | 727 | return node; |
672 | 728 | } |
@@ -720,10 +776,12 @@ export function transformSpyCallInspection(node: ts.Node, refactorCtx: RefactorC |
720 | 776 | } |
721 | 777 | break; |
722 | 778 | case 'mostRecent': |
| 779 | + // Accesses such as `.args`, `.returnValue`, and `.object` are handled by |
| 780 | + // `transformCallInfoProperty`; only a bare `mostRecent()` requires manual migration. |
723 | 781 | if ( |
724 | 782 | !ts.isPropertyAccessExpression(node.parent) || |
725 | 783 | !ts.isIdentifier(node.parent.name) || |
726 | | - node.parent.name.text !== 'args' |
| 784 | + !CALL_INFO_PROPERTIES.has(node.parent.name.text) |
727 | 785 | ) { |
728 | 786 | const category = 'mostRecent-without-args'; |
729 | 787 | reporter.recordTodo(category, sourceFile, node); |
|
0 commit comments