diff --git a/README.md b/README.md index 543a65f3..5fd7e5f6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Codemods that simplify migrating JavaScript and TypeScript test files from [Mocha](https://github.com/mochajs/mocha), [proxyquire](https://github.com/thlorenz/proxyquire), [Should.js](https://github.com/tj/should.js/), -[Tape](https://github.com/substack/tape) +[Tape](https://github.com/substack/tape), +[Sinon](https://github.com/sinonjs/), and [Node-Tap](https://github.com/tapjs/node-tap) to [Jest](https://facebook.github.io/jest/). @@ -82,6 +83,7 @@ $ jscodeshift -t node_modules/jest-codemods/dist/transformers/jasmine-this.js te $ jscodeshift -t node_modules/jest-codemods/dist/transformers/mocha.js test-folder $ jscodeshift -t node_modules/jest-codemods/dist/transformers/should.js test-folder $ jscodeshift -t node_modules/jest-codemods/dist/transformers/tape.js test-folder +$ jscodeshift -t node_modules/jest-codemods/dist/transformers/sinon.js test-folder ``` ## Test environment: Jest on Node.js or other diff --git a/src/cli/index.ts b/src/cli/index.ts index b0bad690..3f5c1f2f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -60,6 +60,7 @@ const TRANSFORMER_JASMINE_THIS = 'jasmine-this' const TRANSFORMER_MOCHA = 'mocha' const TRANSFORMER_SHOULD = 'should' const TRANSFORMER_TAPE = 'tape' +const TRANSFORMER_SINON = 'sinon' const ALL_TRANSFORMERS = [ // TRANSFORMER_CHAI_SHOULD & TRANSFORMER_SHOULD doesn't have import detection @@ -70,6 +71,7 @@ const ALL_TRANSFORMERS = [ TRANSFORMER_MOCHA, TRANSFORMER_TAPE, TRANSFORMER_JASMINE_THIS, + TRANSFORMER_SINON, ] const TRANSFORMER_INQUIRER_CHOICES = [ @@ -113,6 +115,10 @@ const TRANSFORMER_INQUIRER_CHOICES = [ name: 'Tape / Node-Tap', value: TRANSFORMER_TAPE, }, + { + name: 'Sinon', + value: TRANSFORMER_SINON, + }, { name: 'All of the above (use with care)!', value: 'all', diff --git a/src/transformers/sinon.test.ts b/src/transformers/sinon.test.ts new file mode 100644 index 00000000..54d74c6e --- /dev/null +++ b/src/transformers/sinon.test.ts @@ -0,0 +1,470 @@ +/* eslint-env jest */ +import chalk from 'chalk' + +import { wrapPlugin } from '../utils/test-helpers' +import plugin from './sinon' + +chalk.level = 0 + +const wrappedPlugin = wrapPlugin(plugin) +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation().mockClear() +}) + +function expectTransformation(source, expectedOutput, options = {}) { + const result = wrappedPlugin(source, options) + expect(result).toBe(expectedOutput) + expect(console.warn).toBeCalledTimes(0) +} + +it('removes imports', () => { + expectTransformation( + ` + import foo from 'foo' + import sinon from 'sinon-sandbox'; +`, + ` + import foo from 'foo' +` + ) +}) + +describe('spies and stubs', () => { + it('handles spies', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + const stub = sinon.stub(Api, 'get') + sinon.stub(I18n, 'extend'); + sinon.stub(AirbnbUser, 'current').returns(currentUser); + sinon.spy(I18n, 'extend'); + sinon.spy(); + sinon.spy(() => 'foo'); +`, + ` + const stub = jest.spyOn(Api, 'get').mockClear() + jest.spyOn(I18n, 'extend').mockClear(); + jest.spyOn(AirbnbUser, 'current').mockClear().mockReturnValue(currentUser); + jest.spyOn(I18n, 'extend').mockClear(); + jest.fn(); + jest.fn().mockImplementation(() => 'foo'); +` + ) + }) + + it('handles 3rd argument implementation fn', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + sinon.stub(I18n, 'extend', () => 'foo'); +`, + ` + jest.spyOn(I18n, 'extend').mockClear().mockImplementation(() => 'foo'); +` + ) + }) + + it('mock clear if spy added in beforeEach', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + beforeEach(() => { + sinon.stub(Api, 'get') + const s1 = sinon.stub(I18n, 'extend') + const s2 = sinon.stub(I18n, 'extend').returns('en') + sinon.stub(L10n, 'language').returns('en') + sinon.stub(I18n, 'extend', () => 'foo'); + }) +`, + ` + beforeEach(() => { + jest.spyOn(Api, 'get').mockClear() + const s1 = jest.spyOn(I18n, 'extend').mockClear() + const s2 = jest.spyOn(I18n, 'extend').mockClear().mockReturnValue('en') + jest.spyOn(L10n, 'language').mockClear().mockReturnValue('en') + jest.spyOn(I18n, 'extend').mockClear().mockImplementation(() => 'foo'); + }) +` + ) + }) + + it('handles returns', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + const stub1 = sinon.stub(Api, 'get').returns('foo') + const stub2 = sinon.stub(Api, 'get').returns(Promise.resolve({ foo: '1' })) +`, + ` + const stub1 = jest.spyOn(Api, 'get').mockClear().mockReturnValue('foo') + const stub2 = jest.spyOn(Api, 'get').mockClear().mockReturnValue(Promise.resolve({ foo: '1' })) +` + ) + }) + + it('handles .returnsArg', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + sinon.stub(foo, 'getParam').returnsArg(3); + `, + ` + jest.spyOn(foo, 'getParam').mockClear().mockImplementation((...args) => args[3]); + ` + ) + }) + + it('handles .withArgs returns', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + sinon.stub().withArgs('foo').returns('something') + sinon.stub().withArgs('foo', 'bar').returns('something') + sinon.stub().withArgs('foo', 'bar', 1).returns('something') + sinon.stub(Api, 'get').withArgs('foo', 'bar', 1).returns('something') + const stub = sinon.stub(foo, 'bar').withArgs('foo', 1).returns('something') + sinon.stub(foo, 'bar').withArgs('foo', sinon.match.object).returns('something') + sinon.stub().withArgs('foo', sinon.match.any).returns('something') +`, + ` + jest.fn().mockImplementation((...args) => { + if (args[0] === 'foo') + return 'something'; + }) + jest.fn().mockImplementation((...args) => { + if (args[0] === 'foo' && args[1] === 'bar') + return 'something'; + }) + jest.fn().mockImplementation((...args) => { + if (args[0] === 'foo' && args[1] === 'bar' && args[2] === 1) + return 'something'; + }) + jest.spyOn(Api, 'get').mockClear().mockImplementation((...args) => { + if (args[0] === 'foo' && args[1] === 'bar' && args[2] === 1) + return 'something'; + }) + const stub = jest.spyOn(foo, 'bar').mockClear().mockImplementation((...args) => { + if (args[0] === 'foo' && args[1] === 1) + return 'something'; + }) + jest.spyOn(foo, 'bar').mockClear().mockImplementation((...args) => { + if (args[0] === 'foo' && typeof args[1] === 'object') + return 'something'; + }) + jest.fn().mockImplementation((...args) => { + if (args[0] === 'foo' && args.length >= 2) + return 'something'; + }) +` + ) + }) + + /* + apiStub.getCall(0).args[1].data + apistub.args[1][1] + */ + it('handles .getCall, .getCalls and spy arguments', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + apiStub.getCall(0) + apiStub.getCall(0).args[1].data + dispatch.getCall(0).args[0] + onPaginate.getCall(0).args + api.get.getCall(0).args[0][1] + + api.getCalls()[2] + api.getCalls()[2].args +`, + ` + apiStub.mock.calls[0] + apiStub.mock.calls[0][1].data + dispatch.mock.calls[0][0] + onPaginate.mock.calls[0] + api.get.mock.calls[0][0][1] + + api.mock.calls[2] + api.mock.calls[2] +` + ) + }) + + it('handles .args[n]', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + apiStub.args[2][3] + apiStub.foo.bar.args[2][3] + + // just remove .args + apiStub.mock.calls[0].args[3] +`, + ` + apiStub.mock.calls[2][3] + apiStub.foo.bar.mock.calls[2][3] + + // just remove .args + apiStub.mock.calls[0][3] +` + ) + }) + + it('handles .nthCall', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + apiStub.firstCall + apiStub.firstCall.args[1].data + apiStub.secondCall + apiStub.secondCall.args[1].data + apiStub.thirdCall + apiStub.thirdCall.args[1].data + apiStub.lastCall + apiStub.lastCall.args[1].data +`, + ` + apiStub.mock.calls[0] + apiStub.mock.calls[0][1].data + apiStub.mock.calls[1] + apiStub.mock.calls[1][1].data + apiStub.mock.calls[2] + apiStub.mock.calls[2][1].data + apiStub.mock.lastCall + apiStub.mock.lastCall[1].data +` + ) + }) +}) + +describe('mocks', () => { + it('handles creating mocks', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + const stub = sinon.stub() +`, + ` + const stub = jest.fn() +` + ) + }) + + it('handles resets/clears', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + stub.restore() + Api.get.restore() + Api.get.reset() + sinon.restore() +`, + ` + stub.mockRestore() + Api.get.mockRestore() + Api.get.mockReset() + jest.restoreAllMocks() +` + ) + }) +}) + +describe('sinon.match', () => { + it('handles creating mocks', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + sinon.match({ + foo: 'foo' + }) + sinon.match({ + foo: sinon.match({ + bar: 'bar' + }) + }) + expect(foo).toEqual(sinon.match.number) + foo(sinon.match.number) + foo(sinon.match.string) + foo(sinon.match.object) + foo(sinon.match.func) + foo(sinon.match.array) + foo(sinon.match.any) +`, + ` + expect.objectContaining({ + foo: 'foo' + }) + expect.objectContaining({ + foo: expect.objectContaining({ + bar: 'bar' + }) + }) + expect(foo).toEqual(expect.any(Number)) + foo(expect.any(Number)) + foo(expect.any(String)) + foo(expect.any(Object)) + foo(expect.any(Function)) + foo(expect.any(Array)) + foo(expect.anything()) +` + ) + }) +}) + +describe('spy count and call assertions', () => { + it('handles call count assertions', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + + // basic cases + expect(Api.get.callCount).to.equal(1) + expect(spy.callCount).to.equal(1) + + expect(Api.get.called).to.equal(true) + expect(spy.called).to.equal(true) + expect(Api.get.called).toEqual(true) + expect(spy.called).toEqual(true) + + expect(spy.calledOnce).to.equal(true) + expect(spy.calledTwice).to.equal(true) + expect(spy.calledThrice).to.equal(true) + expect(spy.called).to.equal(true) + + // .to.be + expect(Api.get.callCount).to.be(1) + expect(Api.get.called).to.be(true) + expect(Api.get.called).to.be(false) + expect(Api.get.callCount).toBe(1) + expect(Api.get.called).toBe(true) + + // .not + neg cases + expect(Api.get.callCount).not.to.equal(1) + expect(spy.called).not.to.be(true) + expect(spy.callCount).not.to.be(1) + expect(spy.called).to.be(false) + + // .notCalled cases + expect(spy.notCalled).to.equal(true) + expect(spy.notCalled).to.equal(false) +`, + ` + expect(Api.get).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1) + + expect(Api.get).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() + expect(Api.get).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() + + // .to.be + expect(Api.get).toHaveBeenCalledTimes(1) + expect(Api.get).toHaveBeenCalled() + expect(Api.get).not.toHaveBeenCalled() + expect(Api.get).toHaveBeenCalledTimes(1) + expect(Api.get).toHaveBeenCalled() + + // .not + neg cases + expect(Api.get).not.toHaveBeenCalledTimes(1) + expect(spy).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalledTimes(1) + expect(spy).not.toHaveBeenCalled() + + // .notCalled cases + expect(spy).not.toHaveBeenCalled() + expect(spy).toHaveBeenCalled() +` + ) + }) + + it('handles call counts with args', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + expect(spy.withArgs('foo', bar).called).to.be(true) + expect(spy.withArgs('foo', bar).called).to.be(false) +`, + ` + expect(spy).toHaveBeenCalledWith('foo', bar) + expect(spy).not.toHaveBeenCalledWith('foo', bar) +` + ) + }) + + it('handles calledWith', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + expect(spy.calledWith(1, 2, 3)).to.be(true) + expect(spy.notCalledWith(1, 2, 3)).to.be(true) + expect(spy.calledWith(foo, 'bar')).to.be(false) + expect(spy.notCalledWith(foo, 'bar')).to.be(false) +`, + ` + expect(spy).toHaveBeenCalledWith(1, 2, 3) + expect(spy).not.toHaveBeenCalledWith(1, 2, 3) + expect(spy).not.toHaveBeenCalledWith(foo, 'bar') + expect(spy).toHaveBeenCalledWith(foo, 'bar') +` + ) + }) +}) + +describe('mock timers', () => { + it('handles timers', () => { + expectTransformation( + ` + import sinon from 'sinon-sandbox' + sinon.useFakeTimers() + clock.restore() + clock.tick(5) + + let clock1 + beforeEach(() => { + foo() + clock1 = sinon.useFakeTimers() + bar() + }) + + foo() + const clock = sinon.useFakeTimers() + bar() + + beforeEach(() => { + const clock2 = sinon.useFakeTimers(new Date(2015, 2, 14, 0, 0).getTime()) + clock1 = sinon.useFakeTimers(new Date(2015, 2, 14, 0, 0).getTime()) + }) +`, + ` + jest.useFakeTimers() + jest.useRealTimers() + jest.advanceTimersByTime(5) + + beforeEach(() => { + foo() + jest.useFakeTimers() + bar() + }) + + foo() + jest.useFakeTimers(); + bar() + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(2015, 2, 14, 0, 0).getTime()); + jest.useFakeTimers().setSystemTime(new Date(2015, 2, 14, 0, 0).getTime()) + }) +` + ) + }) +}) diff --git a/src/transformers/sinon.ts b/src/transformers/sinon.ts new file mode 100644 index 00000000..1e53c430 --- /dev/null +++ b/src/transformers/sinon.ts @@ -0,0 +1,663 @@ +import core, { API, FileInfo } from 'jscodeshift' + +import { + chainContainsUtil, + createCallUtil, + getNodeBeforeMemberExpressionUtil, + isExpectCallUtil, +} from '../utils/chai-chain-utils' +import finale from '../utils/finale' +import { removeDefaultImport } from '../utils/imports' +import { findParentOfType } from '../utils/recast-helpers' +import { + expressionContainsProperty, + getExpectArg, + isExpectSinonCall, + isExpectSinonObject, + modifyVariableDeclaration, +} from '../utils/sinon-helpers' + +const SINON_CALL_COUNT_METHODS = [ + 'called', + 'calledOnce', + 'calledTwice', + 'calledThrice', + 'callCount', + 'notCalled', +] +const CHAI_CHAIN_MATCHERS = new Set( + ['be', 'eq', 'eql', 'equal', 'toBe', 'toEqual', 'toBeTruthy', 'toBeFalsy'].map((a) => + a.toLowerCase() + ) +) +const SINON_CALLED_WITH_METHODS = ['calledWith', 'notCalledWith'] +const SINON_SPY_METHODS = ['spy', 'stub'] +const SINON_MOCK_RESETS = { + reset: 'mockReset', + restore: 'mockRestore', +} +const SINON_MATCHERS = { + array: 'Array', + func: 'Function', + number: 'Number', + object: 'Object', + string: 'String', +} +const SINON_MATCHERS_WITH_ARGS = { + array: 'object', + func: 'function', + number: 'number', + object: 'object', + string: 'string', +} +const SINON_NTH_CALLS = new Set(['firstCall', 'secondCall', 'thirdCall', 'lastCall']) +const EXPECT_PREFIXES = new Set(['to']) + +/* + expect(spy.called).to.be(true) -> expect(spy).toHaveBeenCalled() + expect(spy.callCount).to.equal(2) -> expect(spy).toHaveBeenCalledTimes(2) +*/ +function transformCallCountAssertions(j, ast) { + const chainContains = chainContainsUtil(j) + const getAllBefore = getNodeBeforeMemberExpressionUtil(j) + const createCall = createCallUtil(j) + + ast + .find(j.CallExpression, { + callee: { + type: j.MemberExpression.name, + property: { + name: (name) => CHAI_CHAIN_MATCHERS.has(name.toLowerCase?.()), + }, + object: (node) => + isExpectSinonObject(node, SINON_CALL_COUNT_METHODS) && + isExpectCallUtil(j, node), + }, + }) + .replaceWith((np) => { + const { node } = np + const expectArg = getExpectArg(node.callee) + + // remove .called/.callCount/etc prop from expect argument + // eg: expect(Api.get.callCount) -> expect(Api.get) + j(np) + .find(j.CallExpression, { + callee: { name: 'expect' }, + }) + .forEach((np) => { + np.node.arguments = [expectArg.object] + }) + + /* + handle `expect(spy.withArgs('foo').called).to.be(true)` -> + `expect(spy.calledWith(1,2,3)).to.be(true)` + and let subsequent transform fn take care of converting to + the final form (ie: see `transformCalledWithAssertions`) + */ + if (expectArg.object.callee?.property?.name === 'withArgs') { + // change .withArgs() -> .calledWith() + expectArg.object.callee.property.name = 'calledWith' + return node + } + + const expectArgSinonMethod = expectArg.property.name + + const isPrefix = (name) => EXPECT_PREFIXES.has(name) + const negated = + chainContains('not', node.callee, isPrefix) || node.arguments?.[0].value === false // eg: .to.be(false) + const rest = getAllBefore(isPrefix, node.callee, 'should') + + switch (expectArgSinonMethod) { + case 'notCalled': + return createCall('toHaveBeenCalled', [], rest, !negated) + case 'calledTwice': + return createCall('toHaveBeenCalledTimes', [j.literal(2)], rest, negated) + case 'calledOnce': + return createCall('toHaveBeenCalledTimes', [j.literal(1)], rest, negated) + case 'called': + case 'calledThrice': + return createCall('toHaveBeenCalled', [], rest, negated) + default: + // eg: .callCount + return createCall( + 'toHaveBeenCalledTimes', + node.arguments.length ? [node.arguments[0]] : [], + rest, + negated + ) + } + }) +} + +/* + expect(spy.calledWith(1, 2, 3)).to.be(true) -> expect(spy).toHaveBeenCalledWith(1, 2, 3); + + https://github.com/jordalgo/jest-codemods/blob/7de97c1d0370c7915cf5e5cc2a860bc5dd96744b/src/transformers/sinon.js#L267 +*/ +function transformCalledWithAssertions(j, ast) { + const chainContains = chainContainsUtil(j) + const getAllBefore = getNodeBeforeMemberExpressionUtil(j) + const createCall = createCallUtil(j) + + ast + .find(j.CallExpression, { + callee: { + type: j.MemberExpression.name, + property: { + name: (name) => CHAI_CHAIN_MATCHERS.has(name.toLowerCase?.()), + }, + object: (node) => + isExpectSinonCall(node, SINON_CALLED_WITH_METHODS) && isExpectCallUtil(j, node), + }, + }) + .replaceWith((np) => { + const { node } = np + const expectArg = getExpectArg(node.callee) + + // remove .calledWith() call from expect argument + j(np) + .find(j.CallExpression, { + callee: { name: 'expect' }, + }) + .forEach((np) => { + np.node.arguments = [expectArg.callee.object] + }) + + const expectArgSinonMethod = expectArg.callee?.property?.name + const isPrefix = (name) => EXPECT_PREFIXES.has(name) + const negated = + chainContains('not', node.callee, isPrefix) || node.arguments?.[0].value === false // eg: .to.be(false) + const rest = getAllBefore(isPrefix, node.callee, 'should') + + switch (expectArgSinonMethod) { + case 'calledWith': + return createCall('toHaveBeenCalledWith', expectArg.arguments, rest, negated) + case 'notCalledWith': + return createCall('toHaveBeenCalledWith', expectArg.arguments, rest, !negated) + default: + return node + } + }) +} + +/* +sinon.stub(Api, 'get') -> jest.spyOn(Api, 'get') +*/ +function transformStub(j: core.JSCodeshift, ast, sinonExpression) { + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: (name) => SINON_SPY_METHODS.includes(name), + }, + object: { + type: 'Identifier', + name: sinonExpression, + }, + }, + }) + .replaceWith((np) => { + const args = np.value.arguments + + // stubbing/spyOn module + if (args.length >= 2) { + let spyOn = j.callExpression( + j.memberExpression(j.identifier('jest'), j.identifier('spyOn')), + args.slice(0, 2) + ) + + // add mockClear since jest doesn't reset the stub on re-declaration like sinon does + spyOn = j.callExpression(j.memberExpression(spyOn, j.identifier('mockClear')), []) + + // add mockImplementation call + if (args.length === 3) { + spyOn = j.callExpression( + j.memberExpression(spyOn, j.identifier('mockImplementation')), + [args[2]] + ) + } + + return spyOn + } + + const jestFnCall = j.callExpression(j.identifier('jest.fn'), []) + + if (args.length === 1) { + return j.callExpression( + j.memberExpression(jestFnCall, j.identifier('mockImplementation')), + args + ) + } + + // jest mock function + return jestFnCall + }) +} + +/* + stub.getCall(0) -> stub.mock.calls[0] + stub.getCall(0).args[1] -> stub.mock.calls[0][1] + stub.firstCall|lastCall|thirdCall|secondCall -> stub.mock.calls[n] +*/ +function transformStubGetCalls(j: core.JSCodeshift, ast) { + // transform .getCall + ast + .find(j.CallExpression, { + callee: { + property: { + name: (n) => ['getCall', 'getCalls'].includes(n), + }, + }, + }) + .replaceWith((np) => { + const { node } = np + const withMockCall = j.memberExpression( + j.memberExpression(node.callee.object, j.identifier('mock')), + j.identifier('calls') + ) + if (node.callee.property.name === 'getCall') { + return j.memberExpression( + withMockCall, + // ensure is a literal to prevent something like: `calls.0[0]` + j.literal(node.arguments?.[0]?.value ?? 0) + ) + } + return withMockCall + }) + + // transform .nthCall + ast + .find(j.MemberExpression, { + property: { + name: (name) => SINON_NTH_CALLS.has(name), + }, + }) + .replaceWith((np) => { + const { node } = np + const { name } = node.property + + const createMockCall = (n) => { + const nth = j.literal(n) + return j.memberExpression(j.memberExpression(node, j.identifier('calls')), nth) + } + + node.property.name = 'mock' + switch (name) { + case 'firstCall': + return createMockCall(0) + case 'secondCall': + return createMockCall(1) + case 'thirdCall': + return createMockCall(2) + case 'lastCall': { + return j.memberExpression(node, j.identifier('lastCall')) + } + } + return node + }) + + // transform .args[n] expression + ast + // match on .args, not the more specific .args[n] + .find(j.MemberExpression, { + property: { + name: 'args', + }, + }) + .replaceWith((np) => { + const { node } = np + + // if contains .mock.calls already, can safely remove .args + if ( + expressionContainsProperty(node, 'mock') && + (expressionContainsProperty(node, 'calls') || + expressionContainsProperty(node, 'lastCall')) + ) { + return np.node.object + } + + /* + replace .args with mock.calls, handles: + stub.args[0][0] -> stub.mock.calls[0][0] + */ + return j.memberExpression(np.node.object, j.identifier('mock.calls')) + }) +} + +/* + handles: + .withArgs + .returns + .returnsArg +*/ +function transformMock(j: core.JSCodeshift, ast) { + // stub.withArgs(111).returns('foo') => stub.mockImplementation((...args) => { if (args[0] === '111') return 'foo' }) + ast + .find(j.CallExpression, { + callee: { + object: { + callee: { + property: { + name: 'withArgs', + }, + }, + }, + property: { name: 'returns' }, + }, + }) + .replaceWith((np) => { + const { node } = np + + // `jest.spyOn` or `jest.fn` + const mockFn = node.callee.object.callee.object + const mockImplementationArgs = node.callee.object.arguments + const mockImplementationReturn = node.arguments + + // unsupported/untransformable .withArgs, just remove .withArgs from chain + if (!mockImplementationArgs?.length || !mockImplementationReturn?.length) { + node.callee = j.memberExpression(mockFn, node.callee.property) + return node + } + + const isSinonMatcherArg = (arg) => + arg.type === 'MemberExpression' && + arg.object?.object?.name === 'sinon' && + arg.object?.property?.name === 'match' + + // generate conditional expression to match args used in .mockImplementation + const mockImplementationConditionalExpression = (mockImplementationArgs as any[]) + .map((arg, i) => { + const argName = j.identifier(`args[${i}]`) + // handle sinon matchers + if (isSinonMatcherArg(arg)) { + const matcherType = SINON_MATCHERS_WITH_ARGS[arg.property.name] + // `sinon.match.object` -> `typeof args[0] === 'object'` + if (matcherType) { + return j.binaryExpression( + '===', + j.unaryExpression('typeof', argName), + j.stringLiteral(matcherType) + ) + } + // handle `sinon.match.any` - check for total number of args, eg: `args.length >= ${expectedArgs} + return j.binaryExpression( + '>=', + j.memberExpression(j.identifier('args'), j.identifier('length')), + j.literal(mockImplementationArgs.length) + ) + } + return j.binaryExpression('===', argName, arg) + }) + .reduce((logicalExp: any, binExp: any, i) => { + if (i === 0) { + return binExp + } + return j.logicalExpression('&&', logicalExp, binExp) + }) + + const mockImplementationFn = j.arrowFunctionExpression( + [j.spreadPropertyPattern(j.identifier('args'))], + j.blockStatement([ + j.ifStatement( + mockImplementationConditionalExpression, + j.returnStatement(mockImplementationReturn[0]) + ), + ]) + ) + + // `jest.fn` or `jest.spyOn` + return j.callExpression( + j.memberExpression(mockFn, j.identifier('mockImplementation')), + [mockImplementationFn] + ) + }) + + // any remaining `.returns()` -> `.mockReturnValue()` + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { type: 'Identifier', name: 'returns' }, + }, + }) + .forEach((np) => { + np.node.callee.property.name = 'mockReturnValue' + }) + + // .returnsArg + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { name: 'returnsArg' }, + }, + }) + .replaceWith((np) => { + const { node } = np + node.callee.property.name = 'mockImplementation' + const argToMock = j.literal(node.arguments[0].value) + + const argsVar = j.identifier('args') + const mockImplementationFn = j.arrowFunctionExpression( + [j.spreadPropertyPattern(argsVar)], + j.memberExpression(argsVar, argToMock) + ) + node.arguments = [mockImplementationFn] + return node + }) +} + +/* + handles mock resets/clears/etc: + sinon.restore() -> jest.restoreAllMocks() + stub.restore() -> stub.mockRestore() + stub.reset() -> stub.mockReset() +*/ +function transformMockResets(j, ast) { + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'sinon', + }, + property: { + type: 'Identifier', + name: 'restore', + }, + }, + }) + .forEach((np) => { + np.node.callee.object.name = 'jest' + np.node.callee.property.name = 'restoreAllMocks' + }) + + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + property: { + type: 'Identifier', + name: (name) => name in SINON_MOCK_RESETS, + }, + }, + }) + .forEach((np) => { + const name = SINON_MOCK_RESETS[np.node.callee.property.name] + np.node.callee.property.name = name + }) +} + +/* + sinon.match({ ... }) -> expect.objectContaining({ ... }) + // .any. matches: + sinon.match.[any|number|string|object|func|array] -> expect.any(type) +*/ +function transformMatch(j, ast) { + ast + .find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'sinon', + }, + property: { + type: 'Identifier', + name: 'match', + }, + }, + }) + .replaceWith((np) => { + const args = np.node.arguments + return j.callExpression(j.identifier('expect.objectContaining'), args) + }) + + ast + .find(j.MemberExpression, { + type: 'MemberExpression', + object: { + object: { + name: 'sinon', + }, + property: { + name: 'match', + }, + }, + }) + .replaceWith((np) => { + const { name } = np.node.property + const constructorType = SINON_MATCHERS[name] + if (constructorType) { + return j.callExpression(j.identifier('expect.any'), [ + j.identifier(constructorType), + ]) + } + return j.callExpression(j.identifier('expect.anything'), []) + }) +} + +function transformMockTimers(j, ast) { + // sinon.useFakeTimers() -> jest.useFakeTimers() + // sinon.useFakeTimers(new Date(...)) -> jest.useFakeTimers().setSystemTime(new Date(...)) + ast + .find(j.CallExpression, { + callee: { + object: { + name: 'sinon', + }, + property: { + name: 'useFakeTimers', + }, + }, + }) + .forEach((np) => { + let { node } = np + node.callee.object.name = 'jest' + + // handle real system time + if (node.arguments?.length) { + const args = node.arguments + node.arguments = [] + node = j.callExpression( + j.memberExpression(node, j.identifier('setSystemTime')), + args + ) + } + + // if `const clock = sinon.useFakeTimers()`, remove variable dec + const parentAssignment = + findParentOfType(np, j.VariableDeclaration.name) || + findParentOfType(np, j.AssignmentExpression.name) + + if (parentAssignment) { + // clock = sinon.useFakeTimers() + if (parentAssignment.value?.type === j.AssignmentExpression.name) { + const varName = parentAssignment.value.left?.name + + // clock = sinon.useFakeTimers() -> sinon.useFakeTimers() + parentAssignment.parentPath.value.expression = node + + // remove global variable declaration + const varNp = np.scope.lookup(varName)?.getBindings()?.[varName]?.[0] + if (varNp) { + modifyVariableDeclaration(varNp, null) + } + + // const clock = sinon.useFakeTimers() -> sinon.useFakeTimers() + } else if (parentAssignment.parentPath.name === 'body') { + modifyVariableDeclaration(np, j.expressionStatement(node)) + } + } + }) + + // clock.tick(n) -> jest.advanceTimersByTime(n) + ast + .find(j.CallExpression, { + callee: { + object: { + type: 'Identifier', + }, + property: { + name: 'tick', + }, + }, + }) + .forEach((np) => { + const { node } = np + node.callee.object.name = 'jest' + node.callee.property.name = 'advanceTimersByTime' + }) + + /* + `stub.restore` shares the same property name as `sinon.useFakeTimers().restore` + so only transform those with `clock` object which seems to be the common name used + for mock timers throughout our codebase + */ + // clock.restore() -> jest.useRealTimers() + ast + .find(j.CallExpression, { + callee: { + object: { + name: 'clock', + }, + property: { + name: 'restore', + }, + }, + }) + .forEach((np) => { + const { node } = np + node.callee.object.name = 'jest' + node.callee.property.name = 'useRealTimers' + }) +} + +export default function transformer(fileInfo: FileInfo, api: API, options) { + const j = api.jscodeshift + const ast = j(fileInfo.source) + + const sinonExpression = removeDefaultImport(j, ast, 'sinon-sandbox') + + if (!sinonExpression) { + console.warn(`no sinon for "${fileInfo.path}"`) + if (!options.skipImportDetection) { + return fileInfo.source + } + return null + } + + transformStub(j, ast, sinonExpression) + transformMockTimers(j, ast) + transformMock(j, ast) + transformMockResets(j, ast) + transformCallCountAssertions(j, ast) + transformCalledWithAssertions(j, ast) + transformMatch(j, ast) + transformStubGetCalls(j, ast) + + return finale(fileInfo, j, ast, options) +} diff --git a/src/utils/chai-chain-utils.ts b/src/utils/chai-chain-utils.ts index 738969b0..56aa051e 100644 --- a/src/utils/chai-chain-utils.ts +++ b/src/utils/chai-chain-utils.ts @@ -100,3 +100,8 @@ export const createCallChainUtil = (j) => (chain, args) => { return j.callExpression(curr, args) } + +export const isExpectCallUtil = (j, node) => + node.name === 'expect' || + (node.type === j.MemberExpression.name && isExpectCallUtil(j, node.object)) || + (node.type === j.CallExpression.name && isExpectCallUtil(j, node.callee)) diff --git a/src/utils/finale.test.ts b/src/utils/finale.test.ts index 4f26dc70..e1cf63e4 100644 --- a/src/utils/finale.test.ts +++ b/src/utils/finale.test.ts @@ -133,7 +133,7 @@ testChanged( unsupportedExample, {}, [ - 'jest-codemods warning: (test.js) Usage of package "sinon" might be incompatible with Jest', + 'jest-codemods warning: (test.js) Usage of package "sinon" might be incompatible with Jest; it\'s recommended the sinon transformer is run first', 'jest-codemods warning: (test.js) Usage of package "testdouble" might be incompatible with Jest', ] ) diff --git a/src/utils/finale.ts b/src/utils/finale.ts index 9a4dd655..1add176a 100644 --- a/src/utils/finale.ts +++ b/src/utils/finale.ts @@ -10,7 +10,14 @@ import detectQuoteStyle from './quote-style' function detectIncompatiblePackages(fileInfo, j, ast) { ;['sinon', 'testdouble'].forEach((pkg) => { if (hasRequireOrImport(j, ast, pkg)) { - logger(fileInfo, `Usage of package "${pkg}" might be incompatible with Jest`) + const msg = `Usage of package "${pkg}" might be incompatible with Jest` + if (pkg === 'sinon') { + return logger( + fileInfo, + `${msg}; it's recommended the sinon transformer is run first` + ) + } + logger(fileInfo, msg) } }) } diff --git a/src/utils/sinon-helpers.ts b/src/utils/sinon-helpers.ts new file mode 100644 index 00000000..6a55e6bc --- /dev/null +++ b/src/utils/sinon-helpers.ts @@ -0,0 +1,61 @@ +import { findParentOfType } from './recast-helpers' + +export function isExpectSinonCall(obj, sinonMethods) { + if (obj.type === 'CallExpression' && obj.callee.name === 'expect') { + const args = obj.arguments + if (args.length) { + return ( + args[0].type === 'CallExpression' && + args[0].callee.type === 'MemberExpression' && + sinonMethods.includes(args[0].callee.property.name) + ) + } + return false + } else if (obj.type === 'MemberExpression') { + return isExpectSinonCall(obj.object, sinonMethods) + } +} + +export function isExpectSinonObject(obj, sinonMethods) { + if (obj.type === 'CallExpression' && obj.callee.name === 'expect') { + const args = obj.arguments + if (args.length) { + return ( + args[0].type === 'MemberExpression' && + sinonMethods.includes(args[0].property.name) + ) + } + return false + } else if (obj.type === 'MemberExpression') { + return isExpectSinonObject(obj.object, sinonMethods) + } +} + +export function getExpectArg(obj) { + if (obj.type === 'MemberExpression') { + return getExpectArg(obj.object) + } else { + return obj.arguments[0] + } +} + +export function modifyVariableDeclaration(nodePath, newNodePath) { + const varDec = findParentOfType(nodePath, 'VariableDeclaration') + if (!varDec) return + varDec.parentPath?.value?.forEach?.((n, i) => { + if (varDec.value === n) { + varDec.parentPath.value[i] = newNodePath + } + }) +} + +export function expressionContainsProperty(node, memberName) { + let current = node + while (current) { + if (current.property?.name === memberName) { + return true + } + current = current.object + } + return false +}