From cc41b88e2284c3d9f2f58c4e504a1a2eda578656 Mon Sep 17 00:00:00 2001 From: Valentin Agachi Date: Sun, 16 Jul 2017 17:18:28 +0200 Subject: [PATCH] Jasmine/Jest v20+: this syntax codemod Closes #57 Context: https://github.com/facebook/jest/issues/3553 --- src/cli/index.js | 6 + src/transformers/jasmine-this.js | 195 +++++++++++++++++ src/transformers/jasmine-this.test.js | 298 ++++++++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 src/transformers/jasmine-this.js create mode 100644 src/transformers/jasmine-this.test.js diff --git a/src/cli/index.js b/src/cli/index.js index 11b8e1d9..69d10d40 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -41,6 +41,7 @@ const TRANSFORMER_CHAI_SHOULD = 'chai-should'; const TRANSFORMER_MOCHA = 'mocha'; const TRANSFORMER_SHOULD = 'should'; const TRANSFORMER_TAPE = 'tape'; +const TRANSFORMER_JASMINE_THIS = 'jasmine-this'; const ALL_TRANSFORMERS = [ TRANSFORMER_AVA, @@ -48,6 +49,7 @@ const ALL_TRANSFORMERS = [ // TRANSFORMER_CHAI_SHOULD & TRANSFORMER_SHOULD doesn't have import detection TRANSFORMER_MOCHA, TRANSFORMER_TAPE, + TRANSFORMER_JASMINE_THIS, ]; function supportFailure(supportedItems) { @@ -76,6 +78,10 @@ inquirer name: 'Chai: Should/Expect BDD Syntax', value: TRANSFORMER_CHAI_SHOULD, }, + { + name: 'Jasmine: this Syntax', + value: TRANSFORMER_JASMINE_THIS, + }, { name: 'Mocha', value: TRANSFORMER_MOCHA, diff --git a/src/transformers/jasmine-this.js b/src/transformers/jasmine-this.js new file mode 100644 index 00000000..56eed8fd --- /dev/null +++ b/src/transformers/jasmine-this.js @@ -0,0 +1,195 @@ +/** + * Codemod for transforming Jamine `this` context into Jest v20+ compatible syntax. + */ + +import detectQuoteStyle from '../utils/quote-style'; + +const testFunctionNames = ['after', 'afterEach', 'before', 'beforeEach', 'it', 'test']; + +const allFunctionNames = ['describe'].concat(testFunctionNames); + +function isFunctionExpressionWithinSpecificFunctions(path, acceptedFunctionNames) { + if (!path || !path.parentPath || !Array.isArray(path.parentPath.value)) { + return false; + } + + const callExpressionPath = path.parentPath.parentPath; + + return ( + !!callExpressionPath && + !!callExpressionPath.value && + callExpressionPath.value.callee.type === 'Identifier' && + acceptedFunctionNames.indexOf(callExpressionPath.value.callee.name) > -1 + ); +} + +function isWithinProperty(path) { + let currentPath = path; + while (currentPath && currentPath.value.type !== 'Property') { + currentPath = currentPath.parentPath; + } + return currentPath ? currentPath.value.type === 'Property' : false; +} + +function isWithinSpecificFunctions(path, acceptedFunctionNames, matchAll) { + if (!matchAll) { + // Do not replace within functions declared as object properties + // See `transforms plain functions within lifecycle methods` test + if (isWithinProperty(path)) { + return false; + } + } + let currentPath = path; + + while ( + currentPath && + currentPath.value && + currentPath.value.type !== 'FunctionExpression' + ) { + currentPath = currentPath.parentPath; + } + + return ( + isFunctionExpressionWithinSpecificFunctions(currentPath, acceptedFunctionNames) || + (currentPath + ? isWithinSpecificFunctions(currentPath.parentPath, testFunctionNames, false) + : false) + ); +} + +function reverseComparator(a, b) { + return a < b ? 1 : -1; +} + +export default function jasmineThis(fileInfo, api) { + const j = api.jscodeshift; + const root = j(fileInfo.source); + + const getValidThisExpressions = node => { + return j(node) + .find(j.MemberExpression, { + object: { + type: j.ThisExpression.name, + }, + }) + .filter(path => isWithinSpecificFunctions(path, allFunctionNames, true)); + }; + + const mutateScope = (ast, body) => { + const replacedIdentifiersMap = {}; + + const updateThisExpressions = () => { + return ast + .find(j.MemberExpression) + .filter(path => path.value.object.type === 'ThisExpression') + .filter(path => isWithinSpecificFunctions(path, allFunctionNames, true)) + .replaceWith(replaceThisExpression) + .size(); + }; + + const replaceThisExpression = path => { + const originalName = path.value.property.name; + const newName = originalName + 'Context'; + replacedIdentifiersMap[originalName] = newName; + return j.identifier(newName); + }; + + const addLetDeclarations = () => { + Object.keys(replacedIdentifiersMap) + // Reverse the keys because we're adding them one by one to the front of the body array + .sort(reverseComparator) + .forEach(originalName => { + body.unshift( + j.variableDeclaration('let', [ + j.variableDeclarator( + j.identifier(replacedIdentifiersMap[originalName]), + null + ), + ]) + ); + }); + }; + + updateThisExpressions(); + addLetDeclarations(); + }; + + const mutateDescribe = path => { + const functionExpression = path.value.arguments.find( + node => + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ); + const functionBody = functionExpression.body; + const ast = j(functionBody); + + mutateScope(ast, functionBody.body); + }; + + const updateRoot = () => { + const topLevelLifecycleMethods = root + .find(j.CallExpression, { + callee: { + type: j.Identifier.name, + name: name => testFunctionNames.indexOf(name) > -1, + }, + }) + // Find only lifecyle methods which are in the root scope + .filter( + path => + path.parentPath.value.type === j.ExpressionStatement.name && + Array.isArray(path.parentPath.parentPath.value) && + path.parentPath.parentPath.parentPath.value.type === j.Program.name + ) + .filter(path => getValidThisExpressions(path.value).size() > 0) + .size(); + + if (topLevelLifecycleMethods > 0) { + const path = root.get(); + mutateScope(root, path.value.program.body); + return 1; + } + + return 0; + }; + + const updateDescribes = () => { + return root + .find(j.CallExpression, { + callee: { + type: j.Identifier.name, + name: 'describe', + }, + }) + .filter(path => getValidThisExpressions(path.value).size() > 0) + .forEach(mutateDescribe) + .size(); + }; + + const updateFunctionExpressions = () => { + return root + .find(j.FunctionExpression) + .filter(path => + isFunctionExpressionWithinSpecificFunctions(path, allFunctionNames) + ) + .replaceWith(path => + j.arrowFunctionExpression( + path.value.params, + path.value.body, + path.value.expression + ) + ) + .size(); + }; + + const mutations = updateRoot() + updateDescribes() + updateFunctionExpressions(); + + if (!mutations) { + return null; + } + + // As Recast is not preserving original quoting, we try to detect it, + // and default to something sane. + const quote = detectQuoteStyle(j, root) || 'single'; + return root.toSource({ quote }); +} diff --git a/src/transformers/jasmine-this.test.js b/src/transformers/jasmine-this.test.js new file mode 100644 index 00000000..c8f8e37d --- /dev/null +++ b/src/transformers/jasmine-this.test.js @@ -0,0 +1,298 @@ +/* eslint-env jest */ +import chalk from 'chalk'; +import { wrapPlugin } from '../utils/test-helpers'; +import plugin from './jasmine-this'; + +chalk.enabled = false; +const wrappedPlugin = wrapPlugin(plugin); + +function testChanged(msg, source, expectedOutput) { + test(msg, () => { + const result = wrappedPlugin(source); + expect(result).toBe(expectedOutput); + }); +} + +testChanged( + 'transforms simple cases', + ` +describe('foo', function() { + beforeEach(function() { + this.foo = { id: 'FOO' }; + this.bar = { id: 'BAR', child: this.foo }; + }); + + it('should have proper IDs', function() { + expect(this.foo.id).to.equal('FOO'); + expect(this.bar.id).to.equal('BAR'); + expect(this.bar.child.id).to.equal('FOO'); + }); +}); +`, + ` +describe('foo', () => { + let barContext; + let fooContext; + beforeEach(() => { + fooContext = { id: 'FOO' }; + barContext = { id: 'BAR', child: fooContext }; + }); + + it('should have proper IDs', () => { + expect(fooContext.id).to.equal('FOO'); + expect(barContext.id).to.equal('BAR'); + expect(barContext.child.id).to.equal('FOO'); + }); +}); +` +); + +testChanged( + 'transforms only test functions context', + ` +describe('foo', function() { + const MockClass = function(options) { + this.options = options; + this.stop = sinon.spy(); + }; + + MockClass.prototype.run = function() { + return this.options.path; + } + + beforeEach(function() { + this.path = '/foo'; + this.mocked = new MockClass({ + limit: 123, + }); + }); + + afterEach(function() { + this.mocked.stop(); + }); + + it('should run with context data', function() { + this.mocked.run({ path: this.path }); + }); +}); + +describe('bar', function () { + describe('ham', function () { + const View = Marionette.ItemView.extend({ + initialize: function (options) { + this.selected = options.selected; + } + }); + }); +}); +`, + ` +describe('foo', () => { + let mockedContext; + let pathContext; + const MockClass = function(options) { + this.options = options; + this.stop = sinon.spy(); + }; + + MockClass.prototype.run = function() { + return this.options.path; + } + + beforeEach(() => { + pathContext = '/foo'; + mockedContext = new MockClass({ + limit: 123, + }); + }); + + afterEach(() => { + mockedContext.stop(); + }); + + it('should run with context data', () => { + mockedContext.run({ path: pathContext }); + }); +}); + +describe('bar', () => { + describe('ham', () => { + const View = Marionette.ItemView.extend({ + initialize: function (options) { + this.selected = options.selected; + } + }); + }); +}); +` +); + +testChanged( + 'transforms nested describes', + ` +describe('foo', function() { + beforeEach(function() { + this.foo = { id: 'FOO' }; + this.bar = { id: 'BAR' }; + }); + + describe('inner foo', function() { + beforeEach(function() { + this.foo = { id: 'OOF' }; + this.ham = { id: 'HAM' }; + }); + + it('should have proper IDs', function() { + const foo = this.foo; + expect(foo.id).to.equal('OOF'); + expect(this.bar.id).to.equal('BAR'); + expect(this.ham.id).to.equal('HAM'); + }); + }); +}); +`, + ` +describe('foo', () => { + let barContext; + let fooContext; + let hamContext; + beforeEach(() => { + fooContext = { id: 'FOO' }; + barContext = { id: 'BAR' }; + }); + + describe('inner foo', () => { + beforeEach(() => { + fooContext = { id: 'OOF' }; + hamContext = { id: 'HAM' }; + }); + + it('should have proper IDs', () => { + const foo = fooContext; + expect(foo.id).to.equal('OOF'); + expect(barContext.id).to.equal('BAR'); + expect(hamContext.id).to.equal('HAM'); + }); + }); +}); +` +); + +testChanged( + 'transforms plain functions within lifecycle methods', + ` +describe('foo', function() { + beforeEach(function() { + this.foo = { id: 'FOO' }; + this.action = function() { + return this.foo; + }; + this.instance = { + action: function() { + this.bar = 123; + }, + other: sinon.spy(function() { + this.bar = 456; + }), + }; + }); + + test('should have proper IDs', function() { + expect(this.foo.id).to.equal('FOO'); + expect(this.action().id).to.equal('FOO'); + this.instance.action(); + this.instance.other(); + }); +}); +`, + ` +describe('foo', () => { + let actionContext; + let fooContext; + let instanceContext; + beforeEach(() => { + fooContext = { id: 'FOO' }; + actionContext = function() { + return fooContext; + }; + instanceContext = { + action: function() { + this.bar = 123; + }, + other: sinon.spy(function() { + this.bar = 456; + }), + }; + }); + + test('should have proper IDs', () => { + expect(fooContext.id).to.equal('FOO'); + expect(actionContext().id).to.equal('FOO'); + instanceContext.action(); + instanceContext.other(); + }); +}); +` +); + +testChanged( + 'transforms context within arrow functions', + ` +describe('foo', () => { + beforeEach(function() { + this.foo = { id: 'FOO' }; + }); + + it('should have proper IDs', function() { + expect(this.foo.id).to.equal('FOO'); + }); +}); +`, + ` +describe('foo', () => { + let fooContext; + beforeEach(() => { + fooContext = { id: 'FOO' }; + }); + + it('should have proper IDs', () => { + expect(fooContext.id).to.equal('FOO'); + }); +}); +` +); + +testChanged( + 'original issue example', + ` +beforeEach(function () { + this.hello = 'hi'; +}); + +afterEach(function () { + console.log(this.hello); +}); + +describe('context', () => { + it('should work', function () { + console.log(this.hello); + }); +}); +`, + ` +let helloContext; +beforeEach(() => { + helloContext = 'hi'; +}); + +afterEach(() => { + console.log(helloContext); +}); + +describe('context', () => { + it('should work', () => { + console.log(helloContext); + }); +}); +` +);