|
|
|
const shell = require('shelljs');
|
|
|
|
const fs = require('fs');
|
|
|
|
const path = require('path');
|
|
|
|
const childprocess = require('child_process');
|
|
|
|
const readline = require('readline');
|
|
|
|
const reqCwd = require('req-cwd');
|
|
|
|
const istanbul = require('istanbul');
|
|
|
|
const getInstrumentedVersion = require('./instrumentSolidity.js');
|
|
|
|
const CoverageMap = require('./coverageMap.js');
|
|
|
|
const defaultTruffleConfig = require('./truffleConfig.js');
|
|
|
|
const preprocessor = require('./preprocessor');
|
|
|
|
|
|
|
|
const isWin = /^win/.test(process.platform);
|
|
|
|
|
|
|
|
const gasLimitHex = 0xfffffffffff; // High gas block limit / contract deployment limit
|
|
|
|
const gasPriceHex = 0x01; // Low gas price
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Coverage Runner
|
|
|
|
*/
|
|
|
|
class App {
|
|
|
|
constructor(config) {
|
|
|
|
this.coverageDir = './coverageEnv'; // Env that instrumented .sols are tested in
|
|
|
|
|
|
|
|
// Options
|
|
|
|
this.network = ''; // Default truffle network execution flag
|
|
|
|
this.silence = ''; // Default log level passed to shell
|
|
|
|
this.log = console.log;
|
|
|
|
|
|
|
|
// Other
|
|
|
|
this.testrpcProcess = null; // ref to testrpc server we need to close on exit
|
|
|
|
this.events = null; // ref to string array loaded from 'allFiredEvents'
|
|
|
|
this.testsErrored = null; // flag set to non-null if truffle tests error
|
|
|
|
this.coverage = new CoverageMap(); // initialize a coverage map
|
|
|
|
|
|
|
|
// Config
|
|
|
|
this.config = config || {};
|
|
|
|
this.workingDir = config.dir || '.'; // Relative path to contracts folder
|
|
|
|
this.accounts = config.accounts || 35; // Number of accounts to testrpc launches with
|
|
|
|
this.skipFiles = config.skipFiles || []; // Which files should be skipped during instrumentation
|
|
|
|
this.norpc = config.norpc || false; // Launch testrpc-sc internally?
|
|
|
|
this.port = config.port || 8555; // Port testrpc should listen on
|
|
|
|
|
|
|
|
this.copyNodeModules = config.copyNodeModules || false; // Copy node modules into coverageEnv?
|
|
|
|
this.copyPackages = config.copyPackages || []; // Only copy specific node_modules packages into coverageEnv
|
|
|
|
this.testrpcOptions = config.testrpcOptions || null; // Options for testrpc-sc
|
|
|
|
this.testCommand = config.testCommand || null; // Optional test command
|
|
|
|
this.compileCommand = config.compileCommand || null; // Optional compile command
|
|
|
|
|
|
|
|
this.setLoggingLevel(config.silent);
|
|
|
|
}
|
|
|
|
|
|
|
|
// -------------------------------------- Methods ------------------------------------------------
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generates a copy of the target project configured for solidity-coverage and saves to
|
|
|
|
* the coverage environment folder. Process exits(1) if try fails
|
|
|
|
*/
|
|
|
|
generateCoverageEnvironment() {
|
|
|
|
this.log('Generating coverage environment');
|
|
|
|
|
|
|
|
try {
|
|
|
|
this.sanityCheckContext();
|
|
|
|
|
|
|
|
let files = shell.ls(this.workingDir);
|
|
|
|
const nmIndex = files.indexOf('node_modules');
|
|
|
|
|
|
|
|
// Removes node_modules from array (unless requested).
|
|
|
|
if (!this.copyNodeModules && nmIndex > -1) {
|
|
|
|
files.splice(nmIndex, 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
files = files.map(file => `${this.workingDir}/${file}`);
|
|
|
|
shell.mkdir(this.coverageDir);
|
|
|
|
shell.cp('-R', files, this.coverageDir);
|
|
|
|
|
|
|
|
// Add specific node_modules packages.
|
|
|
|
if (!this.copyNodeModules && this.copyPackages.length) {
|
|
|
|
shell.mkdir(this.coverageDir + '/node_modules');
|
|
|
|
this.copyPackages.forEach((nodePackage) => {
|
|
|
|
shell.cp('-rf', 'node_modules/' + nodePackage, this.coverageDir + '/node_modules/');
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Load config if present, accomodate common windows naming.
|
|
|
|
let truffleConfig;
|
|
|
|
|
|
|
|
shell.test('-e', `${this.workingDir}/truffle.js`)
|
|
|
|
? truffleConfig = reqCwd.silent(`${this.workingDir}/truffle.js`)
|
|
|
|
: truffleConfig = reqCwd.silent(`${this.workingDir}/truffle-config.js`);
|
|
|
|
|
|
|
|
// Coverage network opts specified: use port if declared
|
|
|
|
if (truffleConfig && truffleConfig.networks && truffleConfig.networks.coverage) {
|
|
|
|
this.port = truffleConfig.networks.coverage.port || this.port;
|
|
|
|
this.network = '--network coverage';
|
|
|
|
|
|
|
|
// No coverage network defaults to the dev network on port 8555, high gas / low price.
|
|
|
|
} else {
|
|
|
|
const trufflejs = defaultTruffleConfig(this.port, gasLimitHex, gasPriceHex);
|
|
|
|
|
|
|
|
(process.platform === 'win32')
|
|
|
|
? fs.writeFileSync(`${this.coverageDir}/truffle-config.js`, trufflejs)
|
|
|
|
: fs.writeFileSync(`${this.coverageDir}/truffle.js`, trufflejs);
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
const msg = ('There was a problem generating the coverage environment: ');
|
|
|
|
this.cleanUp(msg + err);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* For each contract except migrations.sol (or those in skipFiles):
|
|
|
|
* + Generate file path reference for coverage report
|
|
|
|
* + Load contract as string
|
|
|
|
* + Instrument contract
|
|
|
|
* + Save instrumented contract in the coverage environment folder where covered tests will run
|
|
|
|
* + Add instrumentation info to the coverage map
|
|
|
|
*/
|
|
|
|
instrumentTarget() {
|
|
|
|
this.skipFiles = this.skipFiles.map(contract => `${this.coverageDir}/contracts/${contract}`);
|
|
|
|
this.skipFiles.push(`${this.coverageDir}/contracts/Migrations.sol`);
|
|
|
|
|
|
|
|
let currentFile;
|
|
|
|
try {
|
|
|
|
shell.ls(`${this.coverageDir}/contracts/**/*.sol`).forEach(file => {
|
|
|
|
if (!this.skipFiles.includes(file)) {
|
|
|
|
this.log('Instrumenting ', file);
|
|
|
|
|
|
|
|
currentFile = file;
|
|
|
|
const contractPath = this.platformNeutralPath(file);
|
|
|
|
const working = this.workingDir.substring(1);
|
|
|
|
const canonicalPath = contractPath.split('/coverageEnv').join(working);
|
|
|
|
const contract = fs.readFileSync(contractPath).toString();
|
|
|
|
const instrumentedContractInfo = getInstrumentedVersion(contract, canonicalPath);
|
|
|
|
fs.writeFileSync(contractPath, instrumentedContractInfo.contract);
|
|
|
|
this.coverage.addContract(instrumentedContractInfo, canonicalPath);
|
|
|
|
} else {
|
|
|
|
this.log('Skipping instrumentation of ', file);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} catch (err) {
|
|
|
|
const msg = `There was a problem instrumenting ${currentFile}: `;
|
|
|
|
this.cleanUp(msg + err);
|
|
|
|
}
|
|
|
|
this.postProcessPure(this.coverageDir);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Run modified testrpc with large block limit, on (hopefully) unused port.
|
|
|
|
* Changes here should be also be added to the before() block of test/run.js).
|
|
|
|
* @return {Promise} Resolves when testrpc prints 'Listening' to std out / norpc is true.
|
|
|
|
*/
|
|
|
|
launchTestrpc() {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (!this.norpc) {
|
|
|
|
const defaultRpcOptions = `--gasLimit ${gasLimitHex} --accounts ${this.accounts} --port ${this.port}`;
|
|
|
|
const options = this.testrpcOptions || defaultRpcOptions;
|
|
|
|
const command = './node_modules/ethereumjs-testrpc-sc/build/cli.node.js ';
|
|
|
|
|
|
|
|
// Launch
|
|
|
|
const execOpts = {maxBuffer: 1024 * 1024 * 10};
|
|
|
|
this.testrpcProcess = childprocess.exec(command + options, execOpts, (err, stdout, stderr) => {
|
|
|
|
if (err) {
|
|
|
|
if (stdout) this.log(`testRpc stdout:\n${stdout}`);
|
|
|
|
if (stderr) this.log(`testRpc stderr:\n${stderr}`);
|
|
|
|
this.cleanUp('testRpc errored after launching as a childprocess.');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Resolve when testrpc logs that it's listening.
|
|
|
|
this.testrpcProcess.stdout.on('data', data => {
|
|
|
|
if (data.includes('Listening')) {
|
|
|
|
this.log(`Launched testrpc on port ${this.port}`);
|
|
|
|
return resolve();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
return resolve();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Run truffle (or config.testCommand) over instrumented contracts in the
|
|
|
|
* coverage environment folder. Shell cd command needs to be invoked
|
|
|
|
* as its own statement for command line options to work, apparently.
|
|
|
|
* Also reads the 'allFiredEvents' log.
|
|
|
|
*/
|
|
|
|
runTestCommand() {
|
|
|
|
try {
|
|
|
|
const defaultCommand = `truffle test ${this.network} ${this.silence}`;
|
|
|
|
const command = this.testCommand || defaultCommand;
|
|
|
|
this.log(`Running: ${command}\n(this can take a few seconds)...`);
|
|
|
|
shell.cd(this.coverageDir);
|
|
|
|
shell.exec(command);
|
|
|
|
this.testsErrored = shell.error();
|
|
|
|
shell.cd('./..');
|
|
|
|
} catch (err) {
|
|
|
|
const msg =
|
|
|
|
`
|
|
|
|
There was an error generating coverage. Possible reasons include:
|
|
|
|
1. Another application is using port ${this.port}
|
|
|
|
2. Your test runner (Truffle?) crashed because the tests encountered an error.
|
|
|
|
|
|
|
|
`;
|
|
|
|
this.cleanUp(msg + err);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Run truffle compile (or config.compileCommand) over instrumented contracts in the
|
|
|
|
* coverage environment folder. Shell cd command needs to be invoked
|
|
|
|
* as its own statement for command line options to work, apparently.
|
|
|
|
*/
|
|
|
|
runCompileCommand() {
|
|
|
|
try {
|
|
|
|
const defaultCommand = `truffle compile ${this.network} ${this.silence}`;
|
|
|
|
const command = this.compileCommand || defaultCommand;
|
|
|
|
this.log(`Running: ${command}\n(this can take a few seconds)...`);
|
|
|
|
shell.cd(this.coverageDir);
|
|
|
|
shell.exec(command);
|
|
|
|
this.testsErrored = shell.error();
|
|
|
|
shell.cd('./..');
|
|
|
|
} catch (err) {
|
|
|
|
const msg =
|
|
|
|
`
|
|
|
|
There was an error compiling the contracts.
|
|
|
|
`;
|
|
|
|
this.cleanUp(msg + err);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generate coverage / write coverage report / run istanbul
|
|
|
|
*/
|
|
|
|
generateReport() {
|
|
|
|
const collector = new istanbul.Collector();
|
|
|
|
const reporter = new istanbul.Reporter();
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
// Get events fired during instrumented contracts execution.
|
|
|
|
const stream = fs.createReadStream(`./allFiredEvents`);
|
|
|
|
stream.on('error', err => this.cleanUp('Event trace could not be read.\n' + err));
|
|
|
|
const reader = readline.createInterface({
|
|
|
|
input: stream,
|
|
|
|
});
|
|
|
|
this.events = [];
|
|
|
|
reader
|
|
|
|
.on('line', line => this.events.push(line))
|
|
|
|
.on('close', () => {
|
|
|
|
// Generate Istanbul report
|
|
|
|
try {
|
|
|
|
this.coverage.generate(this.events, `${this.workingDir}/contracts`);
|
|
|
|
const relativeMapping = this.makeKeysRelative(this.coverage.coverage, this.workingDir);
|
|
|
|
const json = JSON.stringify(relativeMapping);
|
|
|
|
fs.writeFileSync('./coverage.json', json);
|
|
|
|
|
|
|
|
collector.add(this.coverage.coverage);
|
|
|
|
reporter.add('html');
|
|
|
|
reporter.add('lcov');
|
|
|
|
reporter.add('text');
|
|
|
|
reporter.write(collector, true, () => {
|
|
|
|
this.log('Istanbul coverage reports generated');
|
|
|
|
this.cleanUp();
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
} catch (err) {
|
|
|
|
const msg = 'There was a problem generating the coverage map / running Istanbul.\n';
|
|
|
|
console.log(err.stack);
|
|
|
|
this.cleanUp(msg + err);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// ------------------------------------------ Utils ----------------------------------------------
|
|
|
|
|
|
|
|
sanityCheckContext(){
|
|
|
|
if (!shell.test('-e', `${this.workingDir}/contracts`)){
|
|
|
|
this.cleanUp("Couldn't find a 'contracts' folder to instrument.");
|
|
|
|
}
|
|
|
|
|
|
|
|
if (shell.test('-e', `${this.workingDir}/${this.coverageDir}`)){
|
|
|
|
shell.rm('-Rf', this.coverageDir);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Relativizes path keys so that istanbul report can be read on Windows
|
|
|
|
* @param {Object} map coverage map generated by coverageMap
|
|
|
|
* @param {[type]} root working directory
|
|
|
|
* @return {[type]} map with relativized keys
|
|
|
|
*/
|
|
|
|
makeKeysRelative(map, root) {
|
|
|
|
const newCoverage = {};
|
|
|
|
Object.keys(map).forEach(pathKey => {
|
|
|
|
newCoverage[path.relative(root, pathKey)] = map[pathKey];
|
|
|
|
});
|
|
|
|
return newCoverage;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Conver absolute paths from Windows, if necessary
|
|
|
|
* @param {String} file path
|
|
|
|
* @return {[type]} normalized path
|
|
|
|
*/
|
|
|
|
platformNeutralPath(file) {
|
|
|
|
return (isWin)
|
|
|
|
? path.resolve(file).split('\\').join('/')
|
|
|
|
: path.resolve(file);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Replaces all occurences of `pure` and `view` modifiers in all .sols
|
|
|
|
* in the coverageEnv before the `contracts` folder is instrumented.
|
|
|
|
* @param {String} env 'coverageEnv' presumably
|
|
|
|
*/
|
|
|
|
postProcessPure(env) {
|
|
|
|
shell.ls(`${env}/**/*.sol`).forEach(file => {
|
|
|
|
const contractPath = this.platformNeutralPath(file);
|
|
|
|
const contract = fs.readFileSync(contractPath).toString();
|
|
|
|
const contractProcessed = preprocessor.run(contract);
|
|
|
|
if (contractProcessed.name && contractProcessed.name === 'SyntaxError' && file.slice(-15) !== 'SimpleError.sol') {
|
|
|
|
console.log(`Warning: The file at ${file} was identified as a Solidity Contract, ` +
|
|
|
|
'but did not parse correctly. You may ignore this warning if it is not a Solidity file, ' +
|
|
|
|
'or your project does not use it');
|
|
|
|
} else {
|
|
|
|
fs.writeFileSync(contractPath, contractProcessed);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
// First, compile the instrumented contracts
|
|
|
|
this.runCompileCommand();
|
|
|
|
// Now, run through the generated ABIs and reset all pure/view/constant functions
|
|
|
|
// so that truffle etc uses 'call' on them.
|
|
|
|
for (let i = 0; i < Object.keys(this.coverage.coverage).length; i += 1) {
|
|
|
|
const canonicalPath = Object.keys(this.coverage.coverage)[i];
|
|
|
|
const contractName = path.basename(canonicalPath, '.sol');
|
|
|
|
const abiPath = this.platformNeutralPath(this.coverageDir + '/build/contracts/' + contractName + '.json');
|
|
|
|
const abi = fs.readFileSync(abiPath);
|
|
|
|
const abiJson = JSON.parse(abi);
|
|
|
|
for (let j = 0; j < abiJson.abi.length; j += 1) {
|
|
|
|
const func = abiJson.abi[j];
|
|
|
|
if (this.coverage.coverage[canonicalPath].pureFunctionNames.indexOf(func.name) > -1) {
|
|
|
|
func.constant = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
fs.writeFileSync(abiPath, JSON.stringify(abiJson));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Allows config to turn logging off (for CI)
|
|
|
|
* @param {Boolean} isSilent
|
|
|
|
*/
|
|
|
|
setLoggingLevel(isSilent) {
|
|
|
|
if (isSilent) {
|
|
|
|
this.silence = '> /dev/null 2>&1';
|
|
|
|
this.log = () => {};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Removes coverage build artifacts, kills testrpc.
|
|
|
|
* Exits (1) and prints msg on error, exits (0) otherwise.
|
|
|
|
* @param {String} err error message
|
|
|
|
*/
|
|
|
|
cleanUp(err) {
|
|
|
|
this.log('Cleaning up...');
|
|
|
|
shell.config.silent = true;
|
|
|
|
shell.rm('-Rf', this.coverageDir);
|
|
|
|
shell.rm('./allFiredEvents');
|
|
|
|
shell.rm('./scTopics');
|
|
|
|
if (this.testrpcProcess) { this.testrpcProcess.kill(); }
|
|
|
|
|
|
|
|
if (err) {
|
|
|
|
this.log(`${err}\nExiting without generating coverage...`);
|
|
|
|
process.exit(1);
|
|
|
|
} else if (this.testsErrored) {
|
|
|
|
this.log('Some truffle tests failed while running coverage');
|
|
|
|
process.exit(1);
|
|
|
|
} else {
|
|
|
|
process.exit(0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = App;
|