diff --git a/.gitignore b/.gitignore index 142affe..70336c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ allFiredEvents scTopics +scDebugLog coverage.json coverage/ node_modules/ diff --git a/bin/exec.js b/bin/exec.js index f005c34..93c1147 100644 --- a/bin/exec.js +++ b/bin/exec.js @@ -1,229 +1,20 @@ #!/usr/bin/env node - -const shell = require('shelljs'); -const fs = require('fs'); +const App = require('./../lib/app.js'); const reqCwd = require('req-cwd'); -const path = require('path'); -const childprocess = require('child_process'); -const istanbul = require('istanbul'); -const getInstrumentedVersion = require('./../lib/instrumentSolidity.js'); -const CoverageMap = require('./../lib/coverageMap.js'); - -const istanbulCollector = new istanbul.Collector(); -const istanbulReporter = new istanbul.Reporter(); - -// Very high gas block limits / contract deployment limits -const gasLimitString = '0xfffffffffff'; -const gasLimitHex = 0xfffffffffff; -const gasPriceHex = 0x01; - -const coverage = new CoverageMap(); - -// Paths -const coverageDir = './coverageEnv'; // Env that instrumented .sols are tested in - -// Options -let coverageOption = '--network coverage'; // Default truffle network execution flag -let silence = ''; // Default log level: configurable by --silence -let log = console.log; - -let testrpcProcess; // ref to testrpc server we need to close on exit -let events; // ref to string loaded from 'allFiredEvents' -let testsErrored = null; // flag set to non-null if truffle tests error +const death = require('death'); +const log = console.log; -// --------------------------------------- Utilities ----------------------------------------------- -/** - * Removes coverage build artifacts, kills testrpc. - * Exits (1) and prints msg on error, exits (0) otherwise. - * @param {String} err error message - */ -function cleanUp(err) { - log('Cleaning up...'); - shell.config.silent = true; - shell.rm('-Rf', `${coverageDir}`); - shell.rm('./allFiredEvents'); - shell.rm('./scTopics'); - if (testrpcProcess) { testrpcProcess.kill(); } - - if (err) { - log(`${err}\nExiting without generating coverage...`); - process.exit(1); - } else if (testsErrored) { - log('Some truffle tests failed while running coverage'); - process.exit(1); - } else { - process.exit(0); - } -} -// --------------------------------------- Script -------------------------------------------------- const config = reqCwd.silent('./.solcover.js') || {}; +const app = new App(config); -const workingDir = config.dir || '.'; // Relative path to contracts folder -let port = config.port || 8555; // Port testrpc listens on -const accounts = config.accounts || 35; // Number of accounts to testrpc launches with -const copyNodeModules = config.copyNodeModules || false; // Whether we copy node_modules when making coverage environment -let skipFiles = config.skipFiles || []; // Which files should be skipped during instrumentation - -// Silence shell and script logging (for solcover's unit tests / CI) -if (config.silent) { - silence = '> /dev/null 2>&1'; - log = () => {}; -} - -// Generate a copy of the target project configured for solcover and save to the coverage -// environment folder. -log('Generating coverage environment'); -try { - let files = shell.ls(workingDir); - const nmIndex = files.indexOf('node_modules'); - - if (!config.copyNodeModules && nmIndex > -1) { - files.splice(nmIndex, 1); // Removes node_modules from array. - } - - files = files.map(file => `${workingDir}/` + file); - shell.mkdir(coverageDir); - shell.cp('-R', files, coverageDir); - - const truffleConfig = reqCwd.silent(`${workingDir}/truffle.js`); - - // Coverage network opts specified: use port if declared - if (truffleConfig && truffleConfig.networks && truffleConfig.networks.coverage) { - port = truffleConfig.networks.coverage.port || port; - - // Coverage network opts NOT specified: default to the development network w/ modified - // port, gasLimit, gasPrice. Export the config object only. - } else { - const trufflejs = ` - module.exports = { - networks: { - development: { - host: "localhost", - network_id: "*", - port: ${port}, - gas: ${gasLimitHex}, - gasPrice: ${gasPriceHex} - } - } - };`; - - coverageOption = ''; - fs.writeFileSync(`${coverageDir}/truffle.js`, trufflejs); - } -} catch (err) { - const msg = ('There was a problem generating the coverage environment: '); - cleanUp(msg + err); -} - -// For each contract except migrations.sol (or those in skipFiles): -// 1. Generate file path reference for coverage report -// 2. Load contract as string -// 3. Instrument contract -// 4. Save instrumented contract in the coverage environment folder where covered tests will run -// 5. Add instrumentation info to the coverage map -skipFiles = skipFiles.map(contract => `${coverageDir}/contracts/` + contract); -skipFiles.push(`${coverageDir}/contracts/Migrations.sol`); - -let currentFile; -try { - shell.ls(`${coverageDir}/contracts/**/*.sol`).forEach(file => { - if (!skipFiles.includes(file)) { - log('Instrumenting ', file); - currentFile = file; - - const contractPath = path.resolve(file); - const canonicalPath = contractPath.split('/coverageEnv').join(''); - const contract = fs.readFileSync(contractPath).toString(); - const instrumentedContractInfo = getInstrumentedVersion(contract, canonicalPath); - fs.writeFileSync(contractPath, instrumentedContractInfo.contract); - coverage.addContract(instrumentedContractInfo, canonicalPath); - } else { - log('Skipping instrumentation of ', file); - } - }); -} catch (err) { - const msg = (`There was a problem instrumenting ${currentFile}: `); - cleanUp(msg + err); -} - -new Promise((resolve, reject) => { - // 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). - if (!config.norpc) { - const defaultRpcOptions = `--gasLimit ${gasLimitString} --accounts ${accounts} --port ${port}`; - const testrpcOptions = config.testrpcOptions || defaultRpcOptions; - const command = './node_modules/ethereumjs-testrpc-sc/bin/testrpc '; - - testrpcProcess = childprocess.exec(command + testrpcOptions, null, (err, stdout, stderr) => { - if (err) { - if(stdout) log(`testRpc stdout:\n${stdout}`); - if(stderr) log(`testRpc stderr:\n${stderr}`); - cleanUp('testRpc errored after launching as a childprocess.'); - } - }); - testrpcProcess.stdout.on('data', data => { - if (data.includes('Listening')) { - log(`Launched testrpc on port ${port}`); - return resolve(); - } - }); - } else { - return resolve(); - } -}).then(() => { - // 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. - try { - log('Launching test command (this can take a few seconds)...'); - const defaultCommand = `truffle test ${coverageOption} ${silence}`; - const command = config.testCommand || defaultCommand; - shell.cd('./coverageEnv'); - shell.exec(command); - testsErrored = shell.error(); - shell.cd('./..'); - } catch (err) { - cleanUp(err); - } - - // Get events fired during instrumented contracts execution. - try { - events = fs.readFileSync('./allFiredEvents').toString().split('\n'); - events.pop(); - } catch (err) { - const msg = - ` - There was an error generating coverage. Possible reasons include: - 1. Another application is using port ${port} - 2. Truffle crashed because your tests errored - - `; - cleanUp(msg + err); - } - - // Generate coverage / write coverage report / run istanbul - try { - coverage.generate(events, './contracts'); - - const json = JSON.stringify(coverage.coverage); - fs.writeFileSync('./coverage.json', json); +app.generateCoverageEnvironment(); +app.instrumentTarget(); +app.launchTestrpc() + .then(() => { + app.runTestCommand(); + app.generateReport(); + }) + .catch(err => log(err)); - istanbulCollector.add(coverage.coverage); - istanbulReporter.add('html'); - istanbulReporter.add('lcov'); - istanbulReporter.add('text'); - istanbulReporter.write(istanbulCollector, true, () => { - log('Istanbul coverage reports generated'); - }); - } catch (err) { - if (config.testing) { - cleanUp(); - } else { - const msg = 'There was a problem generating producing the coverage map / running Istanbul.\n'; - cleanUp(msg + err); - } - } +death((signal, err) => app.cleanUp(err)); - // Finish - cleanUp(); -}); diff --git a/circle.yml b/circle.yml index 10befe7..07cf94c 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,9 @@ machine: node: version: 6.11.0 +dependencies: + override: + - npm install test: override: - npm run test-cov diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..9f66ad7 --- /dev/null +++ b/lib/app.js @@ -0,0 +1,266 @@ +const shell = require('shelljs'); +const fs = require('fs'); +const path = require('path'); +const childprocess = require('child_process'); +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 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: configurable by --silence + this.log = console.log; + + // Other + this.testrpcProcess; // ref to testrpc server we need to close on exit + this.events; // ref to string 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.testrpcOptions = config.testrpcOptions || null; // Options for testrpc-sc + this.testCommand = config.testCommand || null; // Optional test 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 { + 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); + + const truffleConfig = reqCwd.silent(`${this.workingDir}/truffle.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) + 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 = path.resolve(file); + const canonicalPath = contractPath.split(this.coverageDir).join(''); + 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); + } + } + + /** + * 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/bin/testrpc '; + + // Launch + this.testrpcProcess = childprocess.exec(command + options, null, (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) { + this.cleanUp(err); + } + + // Get events fired during instrumented contracts execution. + try { + this.events = fs.readFileSync('./allFiredEvents').toString().split('\n'); + this.events.pop(); + } catch (err) { + const msg = + ` + There was an error generating coverage. Possible reasons include: + 1. Another application is using port ${this.port} + 2. Truffle crashed because your tests errored + + `; + 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) => { + try { + this.coverage.generate(this.events, './contracts'); + + const json = JSON.stringify(this.coverage.coverage); + 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'; + this.cleanUp(msg + err); + + } + }) + } + + // ------------------------------------------ Utils ---------------------------------------------- + + /** + * 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; diff --git a/lib/coverageMap.js b/lib/coverageMap.js index 6f9d191..813c891 100644 --- a/lib/coverageMap.js +++ b/lib/coverageMap.js @@ -75,10 +75,8 @@ module.exports = class CoverageMap { this.branchTopics.push(branchHash); this.statementTopics.push(statementHash); - fs.appendFileSync('./scTopics', `${lineHash}\n`); - fs.appendFileSync('./scTopics', `${fnHash}\n`); - fs.appendFileSync('./scTopics', `${branchHash}\n`); - fs.appendFileSync('./scTopics', `${statementHash}\n`); + const topics = `${lineHash}\n${fnHash}\n${branchHash}\n${statementHash}\n`; + fs.appendFileSync('./scTopics', topics); } /** diff --git a/lib/truffleConfig.js b/lib/truffleConfig.js new file mode 100644 index 0000000..e1bb15c --- /dev/null +++ b/lib/truffleConfig.js @@ -0,0 +1,14 @@ +module.exports = function truffleConfig(port, gasLimit, gasPrice) { + return ` + module.exports = { + networks: { + development: { + host: "localhost", + network_id: "*", + port: ${port}, + gas: ${gasLimit}, + gasPrice: ${gasPrice} + } + } + };` +}; \ No newline at end of file diff --git a/package.json b/package.json index 473d82a..deb8117 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "license": "ISC", "dependencies": { "commander": "^2.9.0", + "death": "^1.1.0", "ethereumjs-testrpc-sc": "https://github.com/sc-forks/testrpc-sc.git", "istanbul": "^0.4.5", "keccakjs": "^0.2.1", @@ -44,6 +45,6 @@ "merkle-patricia-tree": "~2.1.2", "mocha": "^3.1.0", "solc": "0.4.8", - "truffle": "3.2.5" + "truffle": "3.4.4" } } diff --git a/test/cli.js b/test/app.js similarity index 99% rename from test/cli.js rename to test/app.js index 2dd53e2..fb2e80e 100644 --- a/test/cli.js +++ b/test/app.js @@ -14,7 +14,7 @@ function collectGarbage() { if (global.gc) { global.gc(); } } -describe('cli', () => { +describe('app', () => { let testrpcProcess = null; const script = 'node ./bin/exec.js'; const port = 8555;