Created
November 10, 2019 21:41
-
-
Save macku/88661373c0edafc5ece4a00cf5651c9c to your computer and use it in GitHub Desktop.
A naive happy path QUnit to Jest jscodeshift converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module.exports = function transformer(file, api) { | |
const j = api.jscodeshift; | |
const root = j(file.source); | |
// Helpers | |
const wrapExpectExpression = expression => { | |
if (expression === null) { | |
return null; | |
} | |
return j.expressionStatement(expression); | |
}; | |
const createExpectExpression = ({ matcher, actual, expected = null }) => { | |
return j.memberExpression( | |
j.callExpression(j.identifier('expect'), [actual]), | |
j.callExpression(j.identifier(matcher), expected ? [expected] : []) | |
); | |
}; | |
const createNotExpectExpression = ({ matcher, actual, expected = null }) => { | |
return j.memberExpression( | |
j.memberExpression( | |
j.callExpression(j.identifier('expect'), [actual]), | |
j.identifier('not') | |
), | |
j.callExpression(j.identifier(matcher), expected ? [expected] : []) | |
); | |
}; | |
// Assertions | |
const expectAssertionCreators = { | |
// assert.equal(actual, expected, [message]) | |
// expect(actual).toBe(expected); | |
equal(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toEqual', | |
}); | |
return expectExpresion; | |
}, | |
// assert.notEqual(actual, expected, [message]) | |
// expect(actual).not.toEqual(expected); | |
notEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createNotExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toEqual', | |
}); | |
return expectExpresion; | |
}, | |
// assert.strictEqual(actual, expected, [message]) | |
// expect(actual).not.toBe(expected); | |
strictEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ actual, expected, matcher: 'toBe' }); | |
return expectExpresion; | |
}, | |
// assert.strictEqual(actual, expected, [message]) | |
// expect(actual).not.toBe(expected); | |
notStrictEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createNotExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toBe', | |
}); | |
return expectExpresion; | |
}, | |
// assert.ok(actual, [message]) | |
// expect(actual).toBeTruthy() | |
ok(assertExpression) { | |
const [actual] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ actual, matcher: 'toBeTruthy' }); | |
return expectExpresion; | |
}, | |
// assert.notOk(actual, [message]) | |
// expect(actual).not.toBeTruthy() | |
notOk(assertExpression) { | |
const [actual] = assertExpression.arguments; | |
const expectExpresion = createNotExpectExpression({ actual, matcher: 'toBeTruthy' }); | |
return expectExpresion; | |
}, | |
// assert.deepEqual(actual, [message]) | |
// expect(actual).not.toEqual(expected) | |
deepEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toEqual', | |
}); | |
return expectExpresion; | |
}, | |
// assert.notDeepEqual(actual, expected, [message]) | |
// expect(actual).not.toEqual(expected) | |
notDeepEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createNotExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toEqual', | |
}); | |
return expectExpresion; | |
}, | |
// assert.propEqual(actual, expected, [message]) | |
// expect(actual).toMatchObject(expected) | |
propEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toMatchObject', | |
}); | |
return expectExpresion; | |
}, | |
// assert.notPropEqual(actual, expected, [message]) | |
// expect(actual).not.toMatchObject(expected) | |
notPropEqual(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createNotExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toMatchObject', | |
}); | |
return expectExpresion; | |
}, | |
// assert.objectContains(actual, expected, [message]) | |
// expect(actual).toEqual(expect.objectContaining(expected)) | |
objectContains(assertExpression) { | |
const [actual, expectedValue] = assertExpression.arguments; | |
const expected = j.memberExpression( | |
j.identifier('expect'), | |
j.callExpression(j.identifier('objectContaining'), [expectedValue]) | |
); | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toEqual', | |
}); | |
return expectExpresion; | |
}, | |
// assert.objectContainsProperty(actual, expected, [message]) | |
// expect(actual).toHaveProperty(expected) | |
objectContainsProperty(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toHaveProperty', | |
}); | |
return expectExpresion; | |
}, | |
// assert.throws(actual, [expectedMessage], [message]) | |
// expect(actual).toThrow(expected) | |
throws(assertExpression) { | |
const [actual, expected] = assertExpression.arguments; | |
const expectExpresion = createExpectExpression({ | |
actual, | |
expected, | |
matcher: 'toThrow', | |
}); | |
return expectExpresion; | |
}, | |
expect(assertExpression) { | |
return null; | |
}, | |
}; | |
const getProgramNodes = root => { | |
return root.find(j.Program).get('body'); | |
}; | |
const isRootLevelExpression = root => { | |
const nodes = getProgramNodes(root); | |
return expressionPath => nodes.filter(path => path === expressionPath).length > 0; | |
}; | |
const isHookProperty = property => { | |
const { | |
key: { name: propertyName }, | |
} = property; | |
return propertyName === 'beforeEach' || propertyName === 'afterEach'; | |
}; | |
const isHookCall = statment => { | |
const { expression } = statment; | |
return ( | |
expression && | |
expression.type === 'CallExpression' && | |
expression.callee.type === 'MemberExpression' && | |
expression.callee.object.name === 'hooks' && | |
(expression.callee.property.name === 'beforeEach' || | |
expression.callee.property.name === 'afterEach') | |
); | |
}; | |
const expressionHasHooks = path => { | |
const { expression } = path.value; | |
const [moduleName, maybeHooks] = expression.arguments; | |
if ( | |
!maybeHooks || | |
(maybeHooks.type !== 'ObjectExpression' && | |
(maybeHooks.type !== 'ArrowFunctionExpression' || maybeHooks.params.length === 0)) | |
) { | |
return false; | |
} | |
// e.g QUnit.module('my test', hooks => { | |
if ( | |
maybeHooks.type === 'ArrowFunctionExpression' && | |
maybeHooks.params.length === 1 && | |
maybeHooks.params[0].type === 'Identifier' && | |
maybeHooks.params[0].name === 'hooks' | |
) { | |
return true; | |
} | |
const { properties } = maybeHooks; | |
return properties.some(isHookProperty); | |
}; | |
const getHook = property => { | |
let body = null; | |
const { | |
key: { name }, | |
type, | |
} = property; | |
switch (type) { | |
case 'Property': | |
body = property.value.body; | |
break; | |
case 'ObjectMethod': | |
body = property.body; | |
break; | |
} | |
if (!body) { | |
throw new Error(`Unknown property type "${type}"`); | |
} | |
return { name, body }; | |
}; | |
const getHooks = path => { | |
const { expression } = path.value; | |
const [moduleName, moduleHooks] = expression.arguments; | |
if (moduleHooks.type === 'ArrowFunctionExpression') { | |
// Remove hooks params | |
moduleHooks.params.length = 0; | |
// Find next hooks | |
return moduleHooks.body.body.filter(isHookCall).map(hookCall => { | |
const { expression } = hookCall; | |
const { name } = expression.callee.property; | |
const { body } = expression.arguments[0]; | |
return { name, body }; | |
}); | |
} | |
const { properties } = moduleHooks; | |
// Remove hooks params | |
path.value.expression.arguments.length = 1; | |
return properties.filter(isHookProperty).map(getHook); | |
}; | |
const insertHooks = (hooks, path) => { | |
hooks.forEach(hook => { | |
// if (hook.type === 'ExpressionStatement') { | |
// const { expression } = hook; | |
// const callArguments = expression.arguments; | |
// | |
// const name = expression.callee.property.name; | |
// const { | |
// body: { body }, | |
// } = callArguments[0]; | |
// | |
// path.insertBefore(createHookExpression({ name, body })); | |
// | |
// return; | |
// } | |
const { name, body } = hook; | |
path.insertBefore(createHookExpression({ name, body })); | |
}); | |
}; | |
const createHookExpression = ({ name, body }) => { | |
const hookFunctionExpression = j.functionExpression(null, [], body); | |
const hookExpression = j.callExpression(j.identifier(name), [hookFunctionExpression]); | |
return j.expressionStatement(hookExpression); | |
}; | |
// | |
// Replace async | |
// | |
const asyncDoneDeclarationLocator = { | |
declarations: [ | |
{ | |
type: 'VariableDeclarator', | |
id: { | |
type: 'Identifier', | |
name: 'done', | |
}, | |
init: { | |
type: 'CallExpression', | |
callee: { | |
type: 'MemberExpression', | |
object: { | |
name: 'assert', | |
type: 'Identifier', | |
}, | |
property: { | |
name: 'async', | |
type: 'Identifier', | |
}, | |
}, | |
}, | |
}, | |
], | |
}; | |
const expressionHasAsyncCall = path => { | |
const { expression } = path.value; | |
const [testNameLiteral, testFunctionExpression] = expression.arguments; | |
// Variable deceleration finder | |
const testFunctionPath = j(testFunctionExpression); | |
const assertAsyncDeclarations = testFunctionPath.find( | |
j.VariableDeclaration, | |
asyncDoneDeclarationLocator | |
); | |
return assertAsyncDeclarations.length > 0; | |
}; | |
const qunitTestsWithAsyncExpressions = root | |
.find(j.ExpressionStatement, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'test', | |
}, | |
}, | |
}, | |
}) | |
.filter(expressionHasAsyncCall); | |
// Iterate and replace declarations | |
qunitTestsWithAsyncExpressions.forEach(path => { | |
const { expression } = path.value; | |
const [testNameLiteral, testFunctionExpression] = expression.arguments; | |
const testFunctionPath = j(testFunctionExpression); | |
const assertAsyncDeclarations = testFunctionPath.find( | |
j.VariableDeclaration, | |
asyncDoneDeclarationLocator | |
); | |
// Remove it! | |
assertAsyncDeclarations.replaceWith(null); | |
// Replace assert param to done | |
testFunctionExpression.params[0].name = 'done'; | |
}); | |
// | |
// beforeEach/afterEach | |
// | |
// Find top level Qunit.module calls | |
const qunitModuleRootWithHooksExpressions = root | |
.find(j.ExpressionStatement, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'module', | |
}, | |
}, | |
}, | |
}) | |
.filter(isRootLevelExpression(root)) | |
.filter(expressionHasHooks); | |
qunitModuleRootWithHooksExpressions.forEach(path => { | |
const hooks = getHooks(path); | |
insertHooks(hooks, path); | |
}); | |
// Put all of Qunit.tests that are siblings of Qunit.module into it | |
const qunitModuleRootExpressions = root | |
.find(j.ExpressionStatement, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'module', | |
}, | |
}, | |
}, | |
}) | |
.filter(isRootLevelExpression(root)); | |
const isTestExpression = path => { | |
return ( | |
path.value && | |
j.match(path.value, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'test', | |
}, | |
}, | |
}, | |
}) | |
); | |
}; | |
const isModuleExpression = path => { | |
return j.match(path.value, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'module', | |
}, | |
}, | |
}, | |
}); | |
}; | |
const findAllTestSiblingsOfModules = ({ root, moduleExpression }) => { | |
const nodes = getProgramNodes(root); | |
let include = false; | |
return nodes.filter(path => { | |
if (path.value === moduleExpression) { | |
include = true; | |
return; | |
} | |
if (!isTestExpression(path)) { | |
return; | |
} | |
if (isModuleExpression(path)) { | |
include = false; | |
return; | |
} | |
if (include) { | |
return true; | |
} | |
}); | |
}; | |
const removeRootLevelExpression = testNode => { | |
testNode.replace(null); | |
}; | |
const insertIntoModule = moduleExpression => { | |
const body = []; | |
const block = j.blockStatement(body); | |
const functionExpression = j.functionExpression(null, [], block); | |
moduleExpression.expression.arguments.push(functionExpression); | |
return testNode => { | |
body.push(testNode.value); | |
}; | |
}; | |
qunitModuleRootExpressions.forEach(path => { | |
const moduleExpression = path.value; | |
const testNodes = findAllTestSiblingsOfModules({ root, moduleExpression }); | |
if (!testNodes.length) { | |
return; | |
} | |
testNodes.forEach(insertIntoModule(moduleExpression)); | |
testNodes.forEach(removeRootLevelExpression); | |
}); | |
// API methods | |
const qunitJestMethodMap = { | |
test: 'test', | |
module: 'describe', | |
only: 'test.only', | |
skip: 'test.skip', | |
todo: 'test.skip', | |
// Find Qunit.* calls | |
}; | |
const qunitExpressions = root.find(j.MemberExpression, { | |
object: { | |
name: 'QUnit', | |
}, | |
}); | |
const createNewIdentifier = name => j.identifier(name); | |
// Remove the unused "assert" param from the test function | |
const expressionHasAssertParam = path => { | |
const { expression } = path.value; | |
const [testName, maybeFunctionExpression] = expression.arguments; | |
if (!maybeFunctionExpression || maybeFunctionExpression.type !== 'FunctionExpression') { | |
return false; | |
} | |
const { params } = maybeFunctionExpression; | |
return ( | |
params.length === 1 && params[0].type === 'Identifier' && params[0].name === 'assert' | |
); | |
}; | |
const qunitTestsWithAssertParamExpressions = root | |
.find(j.ExpressionStatement, { | |
expression: { | |
callee: { | |
object: { | |
name: 'QUnit', | |
}, | |
property: { | |
name: 'test', | |
}, | |
}, | |
}, | |
}) | |
.filter(expressionHasAssertParam); | |
qunitTestsWithAssertParamExpressions.forEach(path => { | |
const { expression } = path.value; | |
const [testNameLiteral, testFunctionExpression] = expression.arguments; | |
// Remove usage of "assert" param | |
testFunctionExpression.params.length = 0; | |
}); | |
// Remove QUnit API calls | |
qunitExpressions.replaceWith(function(path) { | |
const methodName = path.value.property.name; | |
const identifierName = qunitJestMethodMap[methodName]; | |
if (!identifierName) { | |
throw new Error('Can\'t find Jest equivalent for "QUnit.' + methodName + '"'); | |
} | |
return createNewIdentifier(identifierName); | |
}); | |
// Find assert.* calls | |
const assertExpressionsStatements = root.find(j.ExpressionStatement, { | |
expression: { | |
callee: { | |
object: { | |
name: 'assert', | |
}, | |
}, | |
}, | |
}); | |
assertExpressionsStatements.replaceWith(function(path) { | |
const { expression: assertExpression } = path.value; | |
const methodName = assertExpression.callee.property.name; | |
const expectAssertionCreator = expectAssertionCreators[methodName]; | |
if (!expectAssertionCreator) { | |
throw new Error('Can\'t find Jest equivalent for "assert.' + methodName + '"'); | |
} | |
const expectExpresion = expectAssertionCreator(assertExpression); | |
return wrapExpectExpression(expectExpresion); | |
}); | |
return root.toSource(); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment