Skip to content
This repository was archived by the owner on Jan 11, 2019. It is now read-only.

Commit e49f849

Browse files
Kenneth Skovhussindresorhus
Kenneth Skovhus
authored andcommitted
Add transform from Tape to AVA (#28)
1 parent eb122c9 commit e49f849

File tree

7 files changed

+751
-37
lines changed

7 files changed

+751
-37
lines changed

cli.js renamed to ava-codemods.js

+6-27
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,18 @@ import arrify from 'arrify';
66
import globby from 'globby';
77
import pkgConf from 'pkg-conf';
88
import inquirer from 'inquirer';
9-
import assign from 'lodash.assign';
109
import npmRunPath from 'npm-run-path';
11-
import isGitClean from 'is-git-clean';
1210
import * as utils from './cli-utils';
1311
import codemods from './codemods';
1412

1513
function runScripts(scripts, files) {
16-
const spawnOptions = Object.assign({}, {
17-
env: assign({}, process.env, {PATH: npmRunPath({cwd: __dirname})}),
14+
const spawnOptions = {
15+
env: Object.assign({}, process.env, {PATH: npmRunPath({cwd: __dirname})}),
1816
stdio: 'inherit'
19-
});
20-
21-
let result;
17+
};
2218

2319
scripts.forEach(script => {
24-
result = execa.sync('jscodeshift', ['-t', script].concat(files), spawnOptions);
20+
const result = execa.sync('jscodeshift', ['-t', script].concat(files), spawnOptions);
2521

2622
if (result.error) {
2723
throw result.error;
@@ -50,25 +46,8 @@ const cli = meow(`
5046

5147
updateNotifier({pkg: cli.pkg}).notify();
5248

53-
let clean = false;
54-
let errorMessage = 'Unable to determine if git directory is clean';
55-
try {
56-
clean = isGitClean.sync(process.cwd(), {files: ['!package.json']});
57-
errorMessage = 'Git directory is not clean';
58-
} catch (err) {}
59-
60-
const ENSURE_BACKUP_MESSAGE = 'Ensure you have a backup of your tests or commit the latest changes before continuing.';
61-
62-
if (!clean) {
63-
if (cli.flags.force) {
64-
console.log(`WARNING: ${errorMessage}. Forcibly continuing.`, ENSURE_BACKUP_MESSAGE);
65-
} else {
66-
console.log(`
67-
ERROR: ${errorMessage}. Refusing to continue.`,
68-
ENSURE_BACKUP_MESSAGE,
69-
'You may use the --force flag to override this safety check.');
70-
process.exit(1);
71-
}
49+
if (!utils.checkGitStatus(cli.flags.force)) {
50+
process.exit(1);
7251
}
7352

7453
codemods.sort(utils.sortByVersion);

cli-utils.js

+27
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const path = require('path');
22
const semver = require('semver');
33
const uniq = require('lodash.uniq');
44
const flatten = require('lodash.flatten');
5+
const isGitClean = require('is-git-clean');
56

67
export function sortByVersion(a, b) {
78
if (a.version === b.version) {
@@ -35,3 +36,29 @@ export function selectScripts(codemods, currentVersion, nextVersion) {
3536

3637
return flatten(scripts).map(script => path.join(__dirname, script));
3738
}
39+
40+
export function checkGitStatus(force) {
41+
let clean = false;
42+
let errorMessage = 'Unable to determine if git directory is clean';
43+
try {
44+
clean = isGitClean.sync(process.cwd(), {files: ['!package.json']});
45+
errorMessage = 'Git directory is not clean';
46+
} catch (err) {}
47+
48+
const ENSURE_BACKUP_MESSAGE = 'Ensure you have a backup of your tests or commit the latest changes before continuing.';
49+
50+
if (!clean) {
51+
if (force) {
52+
console.log(`WARNING: ${errorMessage}. Forcibly continuing.`, ENSURE_BACKUP_MESSAGE);
53+
} else {
54+
console.log(
55+
`ERROR: ${errorMessage}. Refusing to continue.`,
56+
ENSURE_BACKUP_MESSAGE,
57+
'You may use the --force flag to override this safety check.'
58+
);
59+
return false;
60+
}
61+
}
62+
63+
return true;
64+
}

lib/tape.js

+304
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
/**
2+
* Codemod for transforming Tape tests into AVA.
3+
*/
4+
5+
const tapeToAvaAsserts = {
6+
fail: 'fail',
7+
pass: 'pass',
8+
9+
ok: 'truthy',
10+
true: 'truthy',
11+
assert: 'truthy',
12+
13+
notOk: 'falsy',
14+
false: 'falsy',
15+
notok: 'falsy',
16+
17+
error: 'ifError',
18+
ifErr: 'ifError',
19+
iferror: 'ifError',
20+
21+
equal: 'is',
22+
equals: 'is',
23+
isEqual: 'is',
24+
strictEqual: 'is',
25+
strictEquals: 'is',
26+
27+
notEqual: 'not',
28+
notStrictEqual: 'not',
29+
notStrictEquals: 'not',
30+
isNotEqual: 'not',
31+
doesNotEqual: 'not',
32+
isInequal: 'not',
33+
34+
deepEqual: 'deepEqual',
35+
isEquivalent: 'deepEqual',
36+
same: 'deepEqual',
37+
38+
notDeepEqual: 'notDeepEqual',
39+
notEquivalent: 'notDeepEqual',
40+
notDeeply: 'notDeepEqual',
41+
notSame: 'notDeepEqual',
42+
isNotDeepEqual: 'notDeepEqual',
43+
isNotEquivalent: 'notDeepEqual',
44+
isInequivalent: 'notDeepEqual',
45+
46+
skip: 'skip',
47+
throws: 'throws',
48+
doesNotThrow: 'notThrows'
49+
};
50+
51+
const unsupportedTestFunctions = new Set([
52+
// No equivalent in AVA:
53+
'timeoutAfter',
54+
55+
// t.deepEqual is more strict but might be used in some cases:
56+
'deepLooseEqual',
57+
'looseEqual',
58+
'looseEquals',
59+
'notDeepLooseEqual',
60+
'notLooseEqual',
61+
'notLooseEquals'
62+
]);
63+
64+
function detectQuoteStyle(j, ast) {
65+
let detectedQuoting = null;
66+
67+
ast.find(j.Literal, {
68+
value: v => typeof v === 'string',
69+
raw: v => typeof v === 'string'
70+
})
71+
.forEach(p => {
72+
// The raw value is from the original babel source
73+
if (p.value.raw[0] === '\'') {
74+
detectedQuoting = 'single';
75+
}
76+
77+
if (p.value.raw[0] === '"') {
78+
detectedQuoting = 'double';
79+
}
80+
});
81+
82+
return detectedQuoting;
83+
}
84+
85+
/**
86+
* Updates CommonJS and import statements from Tape to AVA
87+
* @return string with test function name if transformations were made
88+
*/
89+
function updateTapeRequireAndImport(j, ast) {
90+
let testFunctionName = null;
91+
ast.find(j.CallExpression, {
92+
callee: {name: 'require'},
93+
arguments: arg => arg[0].value === 'tape'
94+
})
95+
.filter(p => p.value.arguments.length === 1)
96+
.forEach(p => {
97+
p.node.arguments[0].value = 'ava';
98+
testFunctionName = p.parentPath.value.id.name;
99+
});
100+
101+
ast.find(j.ImportDeclaration, {
102+
source: {
103+
type: 'Literal',
104+
value: 'tape'
105+
}
106+
})
107+
.forEach(p => {
108+
p.node.source.value = 'ava';
109+
testFunctionName = p.value.specifiers[0].local.name;
110+
});
111+
112+
return testFunctionName;
113+
}
114+
115+
export default function tapeToAva(fileInfo, api) {
116+
const j = api.jscodeshift;
117+
const ast = j(fileInfo.source);
118+
119+
const testFunctionName = updateTapeRequireAndImport(j, ast);
120+
121+
if (!testFunctionName) {
122+
// No Tape require/import were found
123+
return fileInfo.source;
124+
}
125+
126+
const warnings = new Set();
127+
function logWarning(msg, node) {
128+
if (warnings.has(msg)) {
129+
return;
130+
}
131+
console.warn(`tape-to-ava warning: (${fileInfo.path} line ${node.value.loc.start.line}) ${msg}`);
132+
warnings.add(msg);
133+
}
134+
135+
const transforms = [
136+
function detectUnsupportedNaming() {
137+
// Currently we only support "t" as the test argument name
138+
const validateTestArgument = p => {
139+
const lastArg = p.value.arguments[p.value.arguments.length - 1];
140+
if (lastArg && lastArg.params && lastArg.params[0]) {
141+
const lastArgName = lastArg.params[0].name;
142+
if (lastArgName !== 't') {
143+
logWarning(`argument to test function should be named "t" not "${lastArgName}"`, p);
144+
}
145+
}
146+
};
147+
148+
ast.find(j.CallExpression, {
149+
callee: {
150+
object: {name: testFunctionName}
151+
}
152+
})
153+
.forEach(validateTestArgument);
154+
155+
ast.find(j.CallExpression, {
156+
callee: {name: testFunctionName}
157+
})
158+
.forEach(validateTestArgument);
159+
},
160+
161+
function detectUnsupportedFeatures() {
162+
ast.find(j.CallExpression, {
163+
callee: {
164+
object: {name: 't'},
165+
property: ({name}) => unsupportedTestFunctions.has(name)
166+
}
167+
})
168+
.forEach(p => {
169+
const propertyName = p.value.callee.property.name;
170+
if (propertyName.toLowerCase().indexOf('looseequal') >= 0) {
171+
logWarning(`"${propertyName}" is not supported. Try the stricter "deepEqual" or "notDeepEqual"`, p);
172+
} else {
173+
logWarning(`"${propertyName}" is not supported`, p);
174+
}
175+
});
176+
177+
ast.find(j.CallExpression, {
178+
callee: {
179+
object: {name: testFunctionName},
180+
property: {name: 'createStream'}
181+
}
182+
})
183+
.forEach(p => {
184+
logWarning('"createStream" is not supported', p);
185+
});
186+
},
187+
188+
function updateAssertions() {
189+
ast.find(j.CallExpression, {
190+
callee: {
191+
object: {name: 't'},
192+
property: ({name}) => Object.keys(tapeToAvaAsserts).indexOf(name) >= 0
193+
}
194+
})
195+
.forEach(p => {
196+
const {property} = p.node.callee;
197+
property.name = tapeToAvaAsserts[property.name];
198+
});
199+
},
200+
201+
function testOptionArgument() {
202+
// Convert Tape option parameters, test([name], [opts], cb)
203+
ast.find(j.CallExpression, {
204+
callee: {name: testFunctionName}
205+
}).forEach(p => {
206+
p.value.arguments.forEach(a => {
207+
if (a.type === 'ObjectExpression') {
208+
a.properties.forEach(tapeOption => {
209+
const tapeOptionKey = tapeOption.key.name;
210+
const tapeOptionValue = tapeOption.value.value;
211+
if (tapeOptionKey === 'skip' && tapeOptionValue === true) {
212+
p.value.callee.name += '.cb.serial.skip';
213+
}
214+
215+
if (tapeOptionKey === 'timeout') {
216+
logWarning('"timeout" option is not supported', p);
217+
}
218+
});
219+
220+
p.value.arguments = p.value.arguments.filter(pa => pa.type !== 'ObjectExpression');
221+
}
222+
});
223+
});
224+
},
225+
226+
function updateTapeComments() {
227+
ast.find(j.CallExpression, {
228+
callee: {
229+
object: {name: 't'},
230+
property: {name: 'comment'}
231+
}
232+
})
233+
.forEach(p => {
234+
p.node.callee = 'console.log';
235+
});
236+
},
237+
238+
function updateThrows() {
239+
// The semantics of t.throws(fn, expected, msg) is different.
240+
// - Tape: if `expected` is a string, it is set to msg
241+
// - AVA: if `expected` is a string it is transformed to a function
242+
ast.find(j.CallExpression, {
243+
callee: {
244+
object: {name: 't'},
245+
property: {name: 'throws'}
246+
},
247+
arguments: arg => arg.length === 2 && arg[1].type === 'Literal' && typeof arg[1].value === 'string'
248+
})
249+
.forEach(p => {
250+
const [fn, msg] = p.node.arguments;
251+
p.node.arguments = [fn, j.literal(null), msg];
252+
});
253+
},
254+
255+
function updateTapeOnFinish() {
256+
ast.find(j.CallExpression, {
257+
callee: {
258+
object: {name: testFunctionName},
259+
property: {name: 'onFinish'}
260+
}
261+
})
262+
.forEach(p => {
263+
p.node.callee.property.name = 'after.always';
264+
});
265+
},
266+
267+
function rewriteTestCallExpression() {
268+
// To be on the safe side we rewrite the test(...) function to
269+
// either test.cb.serial(...) or test.serial(...)
270+
//
271+
// - .serial as Tape runs all tests serially
272+
// - .cb for tests containing t.end, as we cannot detect if the
273+
// test have any asynchronicity.
274+
ast.find(j.CallExpression, {
275+
callee: {name: 'test'}
276+
}).forEach(p => {
277+
// TODO: if t.end is in the scope of the test function we could
278+
// remove it and not use cb style.
279+
const containsEndFunction = j(p).find(j.CallExpression, {
280+
callee: {
281+
object: {name: 't'},
282+
property: {name: 'end'}
283+
}
284+
}).size() > 0;
285+
286+
const newTestFunction = containsEndFunction ? 'cb.serial' : 'serial';
287+
288+
p.node.callee = j.memberExpression(
289+
j.identifier('test'),
290+
j.identifier(newTestFunction)
291+
);
292+
});
293+
}
294+
];
295+
296+
transforms.forEach(t => t());
297+
298+
// As Recast is not preserving original quoting, we try to detect it,
299+
// and default to something sane.
300+
// See https://github.com/benjamn/recast/issues/171
301+
// and https://github.com/facebook/jscodeshift/issues/143
302+
const quote = detectQuoteStyle(j, ast) || 'single';
303+
return ast.toSource({quote});
304+
}

0 commit comments

Comments
 (0)