Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add qunit/mocha tests conversion to async/await #112

Merged
merged 4 commits into from
Jun 27, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,43 @@ helper [introduced](https://github.com/ember-cli/ember-cli/pull/4772) in
Ember CLI 1.13.9.


### Tests: Convert `andThen` style async tests to `async/await`

```sh
ember watson:convert-tests-to-async-await <path>
```

Convert (qunit or mocha flavored) acceptance tests to utilize `async/await`

```js
// before:
it('can visit subroutes', function(done) {
visit('/');

andThen(function() {
expect(find('h2').text()).to.be.empty;
});

visit('/foo');

andThen(function() {
expect(find('h2').text()).to.be.equal('this is an acceptance test');
done();
});
});

// after:
it('can visit subroutes', async function() {
await visit('/');

expect(find('h2').text()).to.be.empty;

await visit('/foo');

expect(find('h2').text()).to.be.equal('this is an acceptance test');
});
```

### Ember Data: Async Relationships Default

```sh
Expand Down
13 changes: 11 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,17 @@ program
.description('Remove `isNewSerializerAPI` in Ember Data serializers.')
.option('--dry-run', 'Don\'t modify files, output JSON instead')
.action(function(path, options) {
path = path || 'app/serializers';
watson.removeEmberDataIsNewSerializerAPI(path, options.dryRun);
path = path || 'app/serializers';
watson.removeEmberDataIsNewSerializerAPI(path, options.dryRun);
});

program
.command('convert-tests-to-async-await [testsPath]')
.description('Convert tests to use async/await')
.option('--dry-run', 'Don\'t modify files, output JSON instead')
.action(function(testsPath, options) {
testsPath = testsPath || 'tests';
watson.transformTestsToAsyncAwait(testsPath, options.dryRun);
});

program
Expand Down
20 changes: 20 additions & 0 deletions lib/commands/convert-mocha-tests-to-async-await.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

var Watson = require('../../index');
var watson = new Watson();

module.exports = {
name: 'watson:convert-tests-to-async-await',
description: 'Convert tests to use async/await',
works: 'insideProject',
anonymousOptions: [
'<path>'
],
availableOptions: [
{ name: 'dry-run', type: Boolean, description: 'Run the command in dry-run mode (outputs JSON, non-destructive)', default: false }
],
run: function(commandOptions, rawArgs) {
var path = rawArgs[0] || 'tests';
watson.transformTestsToAsyncAwait(path, commandOptions.dryRun);
}
};
3 changes: 2 additions & 1 deletion lib/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ module.exports = {
'watson:use-destroy-app-helper': require('./use-destroy-app-helper'),
'watson:remove-ember-k': require('./remove-ember-k'),
'watson:replace-needs-with-injection': require('./replace-needs-with-injection'),
'watson:remove-ember-data-is-new-serializer-api': require('./remove-ember-data-is-new-serializer-api')
'watson:remove-ember-data-is-new-serializer-api': require('./remove-ember-data-is-new-serializer-api'),
'watson:convert-mocha-tests-to-async-await': require('./convert-tests-to-async-await')
};
282 changes: 282 additions & 0 deletions lib/formulas/convert-tests-to-async-await.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/*jshint esversion: 6 */
const recast = require('recast');
const builders = recast.types.builders;
const types = recast.types.namedTypes;
const parseAst = require('../helpers/parse-ast');

// TODO await custom async helpers
const ASYNC_HELPERS = [
'click',
'fillIn',
'visit',
'keyEvent',
'triggerEvent',
'wait'
];

/**
* it('test name', function() { ... })
*/
function isMochaTest(node) {
return types.CallExpression.check(node.expression) &&
node.expression.callee.name === 'it' &&
node.expression.arguments.length === 2;
}

/**
* test('test name', function(assert) { ... })
*/
function isQunitTest(node) {
return types.CallExpression.check(node.expression) &&
node.expression.callee.name === 'test' &&
node.expression.arguments.length === 2;
}

/**
* it.skip('test name', function() { ... })
*/
function isMochaSkip(node) {
return types.CallExpression.check(node.expression) &&
types.MemberExpression.check(node.expression.callee) &&
node.expression.callee.object.name === 'it' &&
node.expression.callee.property.name === 'skip' &&
node.expression.arguments.length === 2;
}

function isTest(node) {
return isMochaTest(node) || isMochaSkip(node) || isQunitTest(node);
}

/**
* Only transform old-style async tests which use the `andThen` helper:
*
* it('does something async', function() {
* visit('/route')
* andThen(function() {
* assert(...)
* })
* })
*/
function testUsesAndThen(node) {
let callback = node.expression.arguments[1];
return (types.FunctionExpression.check(callback) || types.ArrowFunctionExpression.check(callback)) &&
types.BlockStatement.check(callback.body) &&
callback.body.body.find(isAndThen);
}

/**
* Whether the test uses mocha's `done` callback:
*
* it('does something async', function(done) {
* visit('/route')
* andThen(function() {
* assert(...)
* done()
* })
* })
*/
function testUsesDone(node) {
let callback = node.expression.arguments[1];
return (types.FunctionExpression.check(callback) || types.ArrowFunctionExpression.check(callback)) &&
callback.params.length > 0 &&
callback.params[0].name === 'done';
}

/**
* visit(...), fillIn(...), etc.
*/
function isAsyncHelper(node) {
return types.CallExpression.check(node.expression) &&
ASYNC_HELPERS.indexOf(node.expression.callee.name) !== -1;
}

/**
* andThen(function() { ... })
*/
function isAndThen(node) {
return types.CallExpression.check(node.expression) &&
node.expression.callee.name === 'andThen' &&
node.expression.arguments.length === 1 &&
(types.FunctionExpression.check(node.expression.arguments[0]) ||
types.ArrowFunctionExpression.check(node.expression.arguments[0]));
}

/**
* done()
*/
function isDone(node) {
// TODO: handle callback named something other than "done"
return types.CallExpression.check(node.expression) &&
node.expression.callee.name === 'done' &&
node.expression.arguments.length === 0;
}

/**
* For example, this usage of `done` is not safe to remove:
*
* it('fetches contacts', function(done) {
* visit('/');
* server.get('/contacts', (db, request) => {
* done();
* });
* });
*/
function isDoneSafeToRemove(path) {
for (let parent = path.parent; !isTest(parent.node); parent = parent.parent) {
let node = parent.node;
if (types.CallExpression.check(node.expression) && !isAndThen(node)) {
return false;
}
}

return true;
}

/**
* Replace `done` callback with async function
*
* Before:
* it('tests something', function(done) { ... })
*
* After:
* it('tests something', async function() { ... })
*/
function transformTestStatement(path, removeDone) {
let callback = path.node.expression.arguments[1];
callback.async = true;

if (removeDone) {
if (callback.params.length > 0 && types.Identifier.check(callback.params[0]) && callback.params[0].name === 'done') {
callback.params.shift();
}
}
}

/**
* Await async helpers
*
* Before:
* visit('/route')
*
* After:
* await visit('/route')
*/
function transformHelpers(path) {
path.node.expression = builders.awaitExpression(path.node.expression);
}

/**
* Remove andThen(...)
*
* Before:
* foo();
* andThen(function() {
* assert(...)
* })
* bar();
*
* After:
* foo();
* assert(...)
* bar();
*/
function transformAndThen(path) {
// TODO: handle naming conflicts when merging scopes
let outerStatements = path.parent.node.body;
let idx = outerStatements.indexOf(path.node);
if (idx !== -1) {
let innerStatements = path.node.expression.arguments[0].body.body;
outerStatements.splice(idx, 1, ...innerStatements);
}
}

/**
* Remove calls to done()
*
* Before:
* andThen(function() {
* assert(...)
* done();
* })
*
* After:
* andThen(function() {
* assert(...)
* })
*/
function transformDone(path) {
let statements = path.parent.node.body;
let idx = statements.indexOf(path.node);
if (idx !== -1) {
statements.splice(idx, 1);
}
}

module.exports = function transform(source) {
const ast = parseAst(source);

const tests = [];
let currentTest;

recast.visit(ast, {
visitExpressionStatement(path) {
let node = path.node;

if (isTest(node)) {
if (!testUsesAndThen(node)) {
// don't transform this test
return false;
}

currentTest = {
test: path,
asyncHelpers: [],
andThens: [],
dones: [],
removeDone: testUsesDone(path.node)
};
tests.push(currentTest);
}

if (currentTest) {
if (isAsyncHelper(node)) {
currentTest.asyncHelpers.push(path);
}

if (isAndThen(node)) {
currentTest.andThens.push(path);
}

if (isDone(node)) {
if (isDoneSafeToRemove(path)) {
currentTest.dones.push(path);
} else {
currentTest.removeDone = false;
}
}
}

this.traverse(path);
},
});

tests.forEach(function({ test, asyncHelpers, andThens, dones, removeDone }) {
transformTestStatement(test, removeDone);

asyncHelpers.forEach(function(path) {
transformHelpers(path);
});

// process before `andThen` transform so the parent node still exists
dones.forEach(function(path) {
transformDone(path);
});

// process in reverse to handle nested `andThen`
andThens.reverse().forEach(function(path) {
transformAndThen(path);
});
});

return recast.print(ast, { tabWidth: 2, quote: 'single' }).code;
};
Loading