Skip to content

Instantly share code, notes, and snippets.

@macku
Created November 10, 2019 21:41
Show Gist options
  • Save macku/88661373c0edafc5ece4a00cf5651c9c to your computer and use it in GitHub Desktop.
Save macku/88661373c0edafc5ece4a00cf5651c9c to your computer and use it in GitHub Desktop.
A naive happy path QUnit to Jest jscodeshift converter
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