diff --git a/dist/plugin-assets/plugin.utils.js b/dist/plugin-assets/plugin.utils.js new file mode 100644 index 0000000..18e5210 --- /dev/null +++ b/dist/plugin-assets/plugin.utils.js @@ -0,0 +1,460 @@ +/** + * A collection of utilities for common tasks plugins will need in the course + * of composing a workflow using the solidity-coverage API + * + * TODO: Sweep back through here and make all `config.truffle_variable` plugin + * platform neutral... + */ + +const PluginUI = require('./truffle.ui'); + +const path = require('path'); +const fs = require('fs-extra'); +const dir = require('node-dir'); +const globby = require('globby'); +const shell = require('shelljs'); +const globalModules = require('global-modules'); +const TruffleProvider = require('@truffle/provider'); + +// === +// UI +// === + +/** + * Displays a list of skipped contracts + * @param {TruffleConfig} config + * @return {Object[]} skipped array of objects generated by `assembleTargets` method + */ +function reportSkipped(config, skipped=[]){ + let started = false; + const ui = new PluginUI(config.logger.log); + + for (let item of skipped){ + if (!started) { + ui.report('instr-skip', []); + started = true; + } + ui.report('instr-skipped', [item.relativePath]); + } +} + +// ======== +// File I/O +// ======== + +/** + * Loads source + * @param {String} _path absolute path + * @return {String} source file + */ +function loadSource(_path){ + return fs.readFileSync(_path).toString(); +} + +/** + * Save a set of instrumented files to a temporary directory. + * @param {Object[]} targets array of targets generated by `assembleTargets` + * @param {[type]} originalDir absolute path to parent directory of original source + * @param {[type]} tempDir absolute path to temp parent directory + */ +function save(targets, originalDir, tempDir){ + let _path; + for (target of targets) { + _path = target.canonicalPath.replace(originalDir, tempDir); + fs.outputFileSync(_path, target.source); + } +} + +/** + * Relativizes an absolute file path, given an absolute parent path + * @param {String} pathToFile + * @param {String} pathToParent + * @return {String} relative path + */ +function toRelativePath(pathToFile, pathToParent){ + return pathToFile.replace(`${pathToParent}${path.sep}`, ''); +} + +/** + * Returns a pair of canonically named temporary directory paths for contracts + * and artifacts. Instrumented assets can be written & compiled to these. + * Then the unit tests can be run, consuming them as sources. + * @param {TruffleConfig} config + * @return {Object} temp paths + */ +function getTempLocations(config){ + const cwd = config.working_directory; + const contractsDirName = '.coverage_contracts'; + const artifactsDirName = '.coverage_artifacts'; + + return { + tempContractsDir: path.join(cwd, contractsDirName), + tempArtifactsDir: path.join(cwd, artifactsDirName) + } +} + +/** + * Checks for existence of contract sources, and sweeps away debris + * left over from an uncontrolled crash. + */ +function checkContext(config, tempContractsDir, tempArtifactsDir){ + const ui = new PluginUI(config.logger.log); + + if (!shell.test('-e', config.contracts_directory)){ + + const msg = ui.generate('sources-fail', [config.contracts_directory]) + throw new Error(msg); + } + + if (shell.test('-e', tempContractsDir)){ + shell.rm('-Rf', tempContractsDir); + } + + if (shell.test('-e', tempArtifactsDir)){ + shell.rm('-Rf', tempArtifactsDir); + } +} + + +// ============================= +// Instrumentation Set Assembly +// ============================= + +function assembleFiles(config, skipFiles=[]){ + let targets; + let skipFolders; + let skipped = []; + + const { + tempContractsDir, + tempArtifactsDir + } = getTempLocations(config); + + checkContext(config, tempContractsDir, tempArtifactsDir); + + shell.mkdir(tempContractsDir); + shell.mkdir(tempArtifactsDir); + + targets = shell.ls(`${config.contracts_directory}/**/*.sol`); + + skipFiles = assembleSkipped(config, targets, skipFiles); + + return assembleTargets(config, targets, skipFiles) +} + +function assembleTargets(config, targets=[], skipFiles=[]){ + const skipped = []; + const filtered = []; + const cd = config.contracts_directory; + + for (let target of targets){ + if (skipFiles.includes(target)){ + + skipped.push({ + canonicalPath: target, + relativePath: toRelativePath(target, cd), + source: loadSource(target) + }) + + } else { + + filtered.push({ + canonicalPath: target, + relativePath: toRelativePath(target, cd), + source: loadSource(target) + }) + } + } + + return { + skipped: skipped, + targets: filtered + } +} + +/** + * Parses the skipFiles option (which also accepts folders) + */ +function assembleSkipped(config, targets, skipFiles=[]){ + const cd = config.contracts_directory; + + // Make paths absolute + skipFiles = skipFiles.map(contract => `${cd}/${contract}`); + skipFiles.push(`${cd}/Migrations.sol`); + + // Enumerate files in skipped folders + const skipFolders = skipFiles.filter(item => path.extname(item) !== '.sol') + + for (let folder of skipFolders){ + for (let target of targets ) { + if (target.indexOf(folder) === 0) + skipFiles.push(target); + } + }; + + return skipFiles; +} + +// ======== +// Truffle +// ======== + +/** + * Returns a list of test files to pass to mocha. + * @param {Object} config truffleConfig + * @return {String[]} list of files to pass to mocha + */ +function getTestFilePaths(config){ + let target; + let ui = new PluginUI(config.logger.log); + + + // Handle --file cli option (subset of tests) + (typeof config.file === 'string') + ? target = globby.sync([config.file]) + : target = dir.files(config.test_directory, { sync: true }) || []; + + // Filter native solidity tests and warn that they're skipped + const solregex = /.*\.(sol)$/; + const hasSols = target.filter(f => f.match(solregex) != null); + + if (hasSols.length > 0) ui.report('sol-tests', [hasSols.length]); + + // Return list of test files + const testregex = /.*\.(js|ts|es|es6|jsx)$/; + return target.filter(f => f.match(testregex) != null); +} + + +/** + * Configures the network. Runs before the server is launched. + * User can request a network from truffle-config with "--network ". + * There are overlapiing options in solcoverjs (like port and providerOptions.network_id). + * Where there are mismatches user is warned & the truffle network settings are preferred. + * + * Also generates a default config & sets the default gas high / gas price low. + * + * @param {TruffleConfig} config + * @param {SolidityCoverage} api + */ +function setNetwork(config, api){ + const ui = new PluginUI(config.logger.log); + + // --network + if (config.network){ + const network = config.networks[config.network]; + + // Check network: + if (!network){ + throw new Error(ui.generate('no-network', [config.network])); + } + + // Check network id + if (!isNaN(parseInt(network.network_id))){ + + // Warn: non-matching provider options id and network id + if (api.providerOptions.network_id && + api.providerOptions.network_id !== parseInt(network.network_id)){ + + ui.report('id-clash', [ parseInt(network.network_id) ]); + } + + // Prefer network defined id. + api.providerOptions.network_id = parseInt(network.network_id); + + } else { + network.network_id = "*"; + } + + // Check port: use solcoverjs || default if undefined + if (!network.port) { + ui.report('no-port', [api.port]); + network.port = api.port; + } + + // Warn: port conflicts + if (api.port !== api.defaultPort && api.port !== network.port){ + ui.report('port-clash', [ network.port ]) + } + + // Prefer network port if defined; + api.port = network.port; + + network.gas = api.gasLimit; + network.gasPrice = api.gasPrice; + + setOuterConfigKeys(config, api, network.network_id); + return; + } + + // Default Network Configuration + config.network = 'soliditycoverage'; + setOuterConfigKeys(config, api, "*"); + + config.networks[config.network] = { + network_id: "*", + port: api.port, + host: api.host, + gas: api.gasLimit, + gasPrice: api.gasPrice + } +} + +/** + * Sets the default `from` account field in the truffle network that will be used. + * This needs to be done after accounts are fetched from the launched client. + * @param {TruffleConfig} config + * @param {Array} accounts + */ +function setNetworkFrom(config, accounts){ + if (!config.networks[config.network].from){ + config.networks[config.network].from = accounts[0]; + } +} + +// Truffle complains that these outer keys *are not* set when running plugin fn directly. +// But throws saying they *cannot* be manually set when running as truffle command. +function setOuterConfigKeys(config, api, id){ + try { + config.network_id = id; + config.port = api.port; + config.host = api.host; + config.provider = TruffleProvider.create(config); + } catch (err){} +} + +/** + * Tries to load truffle module library and reports source. User can force use of + * a non-local version using cli flags (see option). It's necessary to maintain + * a fail-safe lib because feature was only introduced in 5.0.30. Load order is: + * + * 1. local node_modules + * 2. global node_modules + * 3. fail-safe (truffle lib v 5.0.31 at ./plugin-assets/truffle.library) + * + * @param {Object} truffleConfig config + * @return {Module} + */ +function loadTruffleLibrary(config){ + const ui = new PluginUI(config.logger.log); + + // Local + try { + if (config.useGlobalTruffle || config.usePluginTruffle) throw null; + + const lib = require("truffle"); + ui.report('lib-local'); + return lib; + + } catch(err) {}; + + // Global + try { + if (config.usePluginTruffle) throw null; + + const globalTruffle = path.join(globalModules, 'truffle'); + const lib = require(globalTruffle); + ui.report('lib-global'); + return lib; + + } catch(err) {}; + + // Plugin Copy @ v 5.0.31 + try { + if (config.forceLibFailure) throw null; // For err unit testing + + ui.report('lib-warn'); + return require("./truffle.library") + + } catch(err) { + throw new Error(ui.generate('lib-fail', [err])); + }; + +} + +function loadSolcoverJS(config){ + let solcoverjs; + let coverageConfig; + let ui = new PluginUI(config.logger.log); + + + // Handle --solcoverjs flag + (config.solcoverjs) + ? solcoverjs = path.join(config.working_directory, config.solcoverjs) + : solcoverjs = path.join(config.working_directory, '.solcover.js'); + + // Catch solcoverjs syntax errors + if (shell.test('-e', solcoverjs)){ + + try { + coverageConfig = require(solcoverjs); + } catch(error){ + error.message = ui.generate('solcoverjs-fail') + error.message; + throw new Error(error) + } + + // Config is optional + } else { + coverageConfig = {}; + } + + // Truffle writes to coverage config + coverageConfig.log = config.logger.log; + coverageConfig.cwd = config.working_directory; + coverageConfig.originalContractsDir = config.contracts_directory; + + // Solidity-Coverage writes to Truffle config + config.mocha = config.mocha || {}; + + if (coverageConfig.mocha && typeof coverageConfig.mocha === 'object'){ + config.mocha = Object.assign( + config.mocha, + coverageConfig.mocha + ); + } + + return coverageConfig; +} + +// ========================== +// Finishing / Cleanup +// ========================== + +/** + * Silently removes temporary folders and calls api.finish to shut server down + * @param {TruffleConfig} config + * @param {SolidityCoverage} api + * @return {Promise} + */ +async function finish(config, api){ + const { + tempContractsDir, + tempArtifactsDir + } = getTempLocations(config); + + shell.config.silent = true; + shell.rm('-Rf', tempContractsDir); + shell.rm('-Rf', tempArtifactsDir); + + if (api) await api.finish(); +} + +module.exports = { + assembleFiles: assembleFiles, + assembleSkipped: assembleSkipped, + assembleTargets: assembleTargets, + checkContext: checkContext, + finish: finish, + getTempLocations: getTempLocations, + getTestFilePaths: getTestFilePaths, + loadSource: loadSource, + loadSolcoverJS: loadSolcoverJS, + loadTruffleLibrary: loadTruffleLibrary, + reportSkipped: reportSkipped, + save: save, + setNetwork: setNetwork, + setNetworkFrom: setNetworkFrom, + setOuterConfigKeys: setOuterConfigKeys, + checkContext: checkContext, + toRelativePath: toRelativePath +} diff --git a/dist/plugin-assets/truffle.ui.js b/dist/plugin-assets/truffle.ui.js index b247778..da856a8 100644 --- a/dist/plugin-assets/truffle.ui.js +++ b/dist/plugin-assets/truffle.ui.js @@ -21,6 +21,11 @@ class PluginUI extends UI { const kinds = { + 'instr-skip': `\n${c.bold('Coverage skipped for:')}` + + `\n${c.bold('=====================')}\n`, + + 'instr-skipped': `${ds} ${c.grey(args[0])}`, + 'sol-tests': `${w} ${c.red("This plugin cannot run Truffle's native solidity tests: ")}`+ `${args[0]} test(s) will be skipped.\n`, @@ -48,9 +53,9 @@ class PluginUI extends UI { ` --version: version info\n`, - 'truffle-version': `${ct} ${c.bold('truffle')}: v${args[0]}`, - 'ganache-version': `${ct} ${c.bold('ganache-core')}: ${args[0]}`, - 'coverage-version': `${ct} ${c.bold('solidity-coverage')}: v${args[0]}`, + 'versions': `${ct} ${c.bold('truffle')}: v${args[0]}\n` + + `${ct} ${c.bold('ganache-core')}: ${args[0]}\n` + + `${ct} ${c.bold('solidity-coverage')}: v${args[0]}`, 'network': `\n${c.bold('Network Info')}` + `\n${c.bold('============')}\n` + @@ -75,6 +80,9 @@ class PluginUI extends UI { const c = this.chalk; const kinds = { + + 'sources-fail': `${c.red('Cannot locate expected contract sources folder: ')} ${args[0]}`, + 'lib-fail': `${c.red('Unable to load plugin copy of Truffle library module. ')}` + `${c.red('Try installing Truffle >= v5.0.31 locally or globally.\n')}` + `Caught error message: ${args[0]}\n`, diff --git a/dist/truffle.plugin.js b/dist/truffle.plugin.js index 8d0f84b..179e80e 100644 --- a/dist/truffle.plugin.js +++ b/dist/truffle.plugin.js @@ -1,326 +1,114 @@ -const App = require('./../lib/app'); +const API = require('./../lib/api'); +const utils = require('./plugin-assets/plugin.utils'); const PluginUI = require('./plugin-assets/truffle.ui'); const pkg = require('./../package.json'); -const req = require('req-cwd'); const death = require('death'); const path = require('path'); -const dir = require('node-dir'); const Web3 = require('web3'); -const util = require('util'); -const globby = require('globby'); -const shell = require('shelljs'); -const globalModules = require('global-modules'); -const TruffleProvider = require('@truffle/provider'); + /** * Truffle Plugin: `truffle run coverage [options]` - * @param {Object} truffleConfig @truffle/config config + * @param {Object} config @truffle/config config * @return {Promise} */ -async function plugin(truffleConfig){ +async function plugin(config){ let ui; - let app; + let api; let error; let truffle; - let solcoverjs; let testsErrored = false; - // Separate try block because this logic - // runs before app.cleanUp is defined. try { - ui = new PluginUI(truffleConfig.logger.log); - - if(truffleConfig.help) return ui.report('help'); // Exit if --help + death(utils.finish.bind(null, config, api)); // Catch interrupt signals - truffle = loadTruffleLibrary(ui, truffleConfig); - app = new App(loadSolcoverJS(ui, truffleConfig)); + ui = new PluginUI(config.logger.log); - } catch (err) { throw err } + if(config.help) return ui.report('help'); // Exit if --help - try { - // Catch interrupt signals - death(app.cleanUp); + truffle = utils.loadTruffleLibrary(config); + api = new API(utils.loadSolcoverJS(config)); - setNetwork(ui, app, truffleConfig); + utils.setNetwork(config, api); - // Provider / Server launch - const address = await app.ganache(truffle.ganache); + // Server launch + const address = await api.ganache(truffle.ganache); const web3 = new Web3(address); const accounts = await web3.eth.getAccounts(); const nodeInfo = await web3.eth.getNodeInfo(); const ganacheVersion = nodeInfo.split('/')[1]; - setNetworkFrom(truffleConfig, accounts); + utils.setNetworkFrom(config, accounts); // Version Info - ui.report('truffle-version', [truffle.version]); - ui.report('ganache-version', [ganacheVersion]); - ui.report('coverage-version',[pkg.version]); + ui.report('versions', [ + truffle.version, + ganacheVersion, + pkg.version + ]); - if (truffleConfig.version) return app.cleanUp(); // Exit if --version + // Exit if --version + if (config.version) return await utils.finish(config, api); ui.report('network', [ - truffleConfig.network, - truffleConfig.networks[truffleConfig.network].network_id, - truffleConfig.networks[truffleConfig.network].port + config.network, + config.networks[config.network].network_id, + config.networks[config.network].port ]); // Instrument - app.sanityCheckContext(); - app.generateStandardEnvironment(); - app.instrument(); + let { + targets, + skipped + } = utils.assembleFiles(config, api.skipFiles); + + targets = api.instrument(targets); + utils.reportSkipped(config, skipped); // Filesystem & Compiler Re-configuration - truffleConfig.contracts_directory = app.contractsDir; - truffleConfig.build_directory = app.artifactsDir; + const { + tempArtifactsDir, + tempContractsDir + } = utils.getTempLocations(config); + + utils.save(targets, config.contracts_directory, tempContractsDir); + utils.save(skipped, config.contracts_directory, tempContractsDir); - truffleConfig.contracts_build_directory = path.join( - app.artifactsDir, - path.basename(truffleConfig.contracts_build_directory) + config.contracts_directory = tempContractsDir; + config.build_directory = tempArtifactsDir; + + config.contracts_build_directory = path.join( + tempArtifactsDir, + path.basename(config.contracts_build_directory) ); - truffleConfig.all = true; - truffleConfig.test_files = getTestFilePaths(ui, truffleConfig); - truffleConfig.compilers.solc.settings.optimizer.enabled = false; + config.all = true; + config.test_files = utils.getTestFilePaths(config); + config.compilers.solc.settings.optimizer.enabled = false; // Compile Instrumented Contracts - await truffle.contracts.compile(truffleConfig); + await truffle.contracts.compile(config); // Run tests try { - failures = await truffle.test.run(truffleConfig) + failures = await truffle.test.run(config) } catch (e) { error = e.stack; } // Run Istanbul - await app.report(); + await api.report(); } catch(e){ error = e; } - // Finish - await app.cleanUp(); + await utils.finish(config, api); if (error !== undefined) throw error; if (failures > 0) throw new Error(`${failures} test(s) failed under coverage.`) } -// -------------------------------------- Helpers -------------------------------------------------- - -/** - * Returns a list of test files to pass to mocha. - * @param {Object} ui reporter utility - * @param {Object} truffle truffleConfig - * @return {String[]} list of files to pass to mocha - */ -function getTestFilePaths(ui, truffle){ - let target; - - // Handle --file cli option (subset of tests) - (typeof truffle.file === 'string') - ? target = globby.sync([truffle.file]) - : target = dir.files(truffle.test_directory, { sync: true }) || []; - - // Filter native solidity tests and warn that they're skipped - const solregex = /.*\.(sol)$/; - const hasSols = target.filter(f => f.match(solregex) != null); - - if (hasSols.length > 0) ui.report('sol-tests', [hasSols.length]); - - // Return list of test files - const testregex = /.*\.(js|ts|es|es6|jsx)$/; - return target.filter(f => f.match(testregex) != null); -} - -/** - * Configures the network. Runs before the server is launched. - * User can request a network from truffle-config with "--network ". - * There are overlapping options in solcoverjs (like port and providerOptions.network_id). - * Where there are mismatches user is warned & the truffle network settings are preferred. - * - * Also generates a default config & sets the default gas high / gas price low. - * - * @param {SolidityCoverage} app - * @param {TruffleConfig} config - */ -function setNetwork(ui, app, config){ - - // --network - if (config.network){ - const network = config.networks[config.network]; - - // Check network: - if (!network){ - throw new Error(ui.generate('no-network', [config.network])); - } - - // Check network id - if (!isNaN(parseInt(network.network_id))){ - - // Warn: non-matching provider options id and network id - if (app.providerOptions.network_id && - app.providerOptions.network_id !== parseInt(network.network_id)){ - - ui.report('id-clash', [ parseInt(network.network_id) ]); - } - - // Prefer network defined id. - app.providerOptions.network_id = parseInt(network.network_id); - - } else { - network.network_id = "*"; - } - - // Check port: use solcoverjs || default if undefined - if (!network.port) { - ui.report('no-port', [app.port]); - network.port = app.port; - } - - // Warn: port conflicts - if (app.port !== app.defaultPort && app.port !== network.port){ - ui.report('port-clash', [ network.port ]) - } - - // Prefer network port if defined; - app.port = network.port; - - network.gas = app.gasLimit; - network.gasPrice = app.gasPrice; - - setOuterConfigKeys(config, app, network.network_id); - return; - } - - // Default Network Configuration - config.network = 'soliditycoverage'; - setOuterConfigKeys(config, app, "*"); - - config.networks[config.network] = { - network_id: "*", - port: app.port, - host: app.host, - gas: app.gasLimit, - gasPrice: app.gasPrice - } -} - -/** - * Sets the default `from` account field in the truffle network that will be used. - * This needs to be done after accounts are fetched from the launched client. - * @param {TruffleConfig} config - * @param {Array} accounts - */ -function setNetworkFrom(config, accounts){ - if (!config.networks[config.network].from){ - config.networks[config.network].from = accounts[0]; - } -} - -// Truffle complains that these outer keys *are not* set when running plugin fn directly. -// But throws saying they *cannot* be manually set when running as truffle command. -function setOuterConfigKeys(config, app, id){ - try { - config.network_id = id; - config.port = app.port; - config.host = app.host; - config.provider = TruffleProvider.create(config); - } catch (err){} -} - -/** - * Tries to load truffle module library and reports source. User can force use of - * a non-local version using cli flags (see option). Load order is: - * - * 1. local node_modules - * 2. global node_modules - * 3. fail-safe (truffle lib v 5.0.31 at ./plugin-assets/truffle.library) - * - * @param {Object} ui reporter utility - * @param {Object} truffleConfig config - * @return {Module} - */ -function loadTruffleLibrary(ui, truffleConfig){ - - // Local - try { - if (truffleConfig.useGlobalTruffle || truffleConfig.usePluginTruffle) throw null; - - const lib = require("truffle"); - ui.report('lib-local'); - return lib; - - } catch(err) {}; - - // Global - try { - if (truffleConfig.usePluginTruffle) throw null; - - const globalTruffle = path.join(globalModules, 'truffle'); - const lib = require(globalTruffle); - ui.report('lib-global'); - return lib; - - } catch(err) {}; - - // Plugin Copy @ v 5.0.31 - try { - if (truffleConfig.forceLibFailure) throw null; // For err unit testing - - ui.report('lib-warn'); - return require("./plugin-assets/truffle.library") - - } catch(err) { - throw new Error(ui.generate('lib-fail', [err])); - }; - -} - -function loadSolcoverJS(ui, truffleConfig){ - let coverageConfig; - let solcoverjs; - - // Handle --solcoverjs flag - (truffleConfig.solcoverjs) - ? solcoverjs = path.join(truffleConfig.working_directory, truffleConfig.solcoverjs) - : solcoverjs = path.join(truffleConfig.working_directory, '.solcover.js'); - - // Catch solcoverjs syntax errors - if (shell.test('-e', solcoverjs)){ - - try { - coverageConfig = require(solcoverjs); - } catch(error){ - error.message = ui.generate('solcoverjs-fail') + error.message; - throw new Error(error) - } - - // Config is optional - } else { - coverageConfig = {}; - } - - // Truffle writes to coverage config - coverageConfig.log = truffleConfig.logger.log; - coverageConfig.cwd = truffleConfig.working_directory; - coverageConfig.originalContractsDir = truffleConfig.contracts_directory; - - // Solidity-Coverage writes to Truffle config - truffleConfig.mocha = truffleConfig.mocha || {}; - - if (coverageConfig.mocha && typeof coverageConfig.mocha === 'object'){ - truffleConfig.mocha = Object.assign( - truffleConfig.mocha, - coverageConfig.mocha - ); - } - - return coverageConfig; -} - - module.exports = plugin; diff --git a/lib/app.js b/lib/api.js similarity index 51% rename from lib/app.js rename to lib/api.js index 16088a2..6a4e672 100644 --- a/lib/app.js +++ b/lib/api.js @@ -12,12 +12,10 @@ const Coverage = require('./coverage'); const DataCollector = require('./collector'); const AppUI = require('./ui').AppUI; -const isWin = /^win/.test(process.platform); - /** * Coverage Runner */ -class App { +class API { constructor(config) { this.coverage = new Coverage(); this.instrumenter = new Instrumenter(); @@ -29,14 +27,8 @@ class App { // Options this.testsErrored = false; - this.instrumentToFile = (config.instrumentToFile === false) ? false : true; this.cwd = config.cwd || process.cwd(); - this.contractsDirName = '.coverage_contracts'; - this.artifactsDirName = '.coverage_artifacts'; - this.contractsDir = path.join(this.cwd, this.contractsDirName); - this.artifactsDir = path.join(this.cwd, this.artifactsDirName); - this.originalContractsDir = config.originalContractsDir this.server = null; @@ -47,7 +39,6 @@ class App { this.host = config.host || "127.0.0.1"; this.providerOptions = config.providerOptions || {}; - this.skippedFolders = []; this.skipFiles = config.skipFiles || []; this.log = config.log || console.log; @@ -63,87 +54,68 @@ class App { } - // -------------------------------------- Methods ----------------------------------------------- /** - * Setup temp folder, write instrumented contracts to it and register them as coverage targets + * Instruments a set of sources to prepare them for running under coverage + * @param {Object[]} targets (see below) + * @return {Object[]} (see below) + * @example: * - * TODO: This function should be completely rewritten so that file loading, skip-filters and - * saving are done by the plugin API. - * - * Input should be array of these... - * { - * canonicalPath: + * targets: + * [{ + * canonicalPath: + * relativePath: * source: - * } * - * Output should be array of these...: - * { + * },...] + * + * outputs: + * [{ * canonicalPath: * source: - * } + * }...] */ - instrument(targetFiles=[]) { - let targets; + instrument(targets=[]) { let currentFile; // Keep track of filename in case we crash... let started = false; - let skipped = []; + let outputs = []; try { - this.registerSkippedItems(); - - (targetFiles.length) - ? targets = targetFiles - : targets = shell.ls(`${this.contractsDir}/**/*.sol`); + for (let target of targets) { + currentFile = target.relativePath || target.canonicalPath; - targets.forEach(file => { - currentFile = file; - - if (!this.shouldSkip(file)) { - !started && this.ui.report('instr-start'); + if(!started){ started = true; + this.ui.report('instr-start'); + } - // Remember the real path - const contractPath = this.platformNeutralPath(file); - const relativePath = this.toRelativePath(contractPath, this.contractsDirName); - const canonicalPath = path.join( - this.originalContractsDir, - relativePath - ); + this.ui.report('instr-item', [target.relativePath]); - this.ui.report('instr-item', [relativePath]) + const instrumented = this.instrumenter.instrument( + target.source, + target.canonicalPath + ); - // Instrument contract, save, add to coverage map - const contract = this.loadContract(contractPath); - const instrumented = this.instrumenter.instrument(contract, canonicalPath); - this.saveContract(contractPath, instrumented.contract); - this.coverage.addContract(instrumented, canonicalPath); + this.coverage.addContract(instrumented, target.canonicalPath); + + outputs.push({ + canonicalPath: target.canonicalPath, + relativePath: target.relativePath, + source: instrumented.contract + }) + } - } else { - skipped.push(file); - } - }); } catch (err) { - const name = this.toRelativePath(currentFile, this.contractsDirName); - err.message = this.ui.generate('instr-fail', [name]) + err.message; + err.message = this.ui.generate('instr-fail', [currentFile]) + err.message; throw err; } - if (skipped.length > 0){ - this.ui.report('instr-skip'); - - skipped.forEach(item => { - item = item.split(`/${this.contractsDirName}`)[1] - this.ui.report('instr-skipped', [item]) - }); - } + return outputs; } /** - * Launch an in-process ethereum client and hook up the DataCollector to its VM. - * @param {Object} client ethereum client - * @return {Object} provider - * - * TODO: generalize provider options setting for non-ganache clients.. + * Launches an in-process ethereum client server, hooking the DataCollector to its VM. + * @param {Object} client ganache client + * @return {String} address of server to connect to */ async ganache(client){ let retry = false; @@ -214,57 +186,14 @@ class App { }) } - // ============ - // Public Utils - // ============ - - /** - * Should only be run before any temporary folders are created. - * It checks for existence of contract sources, server port conflicts - * and sweeps away debris left over from an uncontrolled crash. - */ - sanityCheckContext(){ - if (!shell.test('-e', this.originalContractsDir)){ - const msg = this.ui.generate('sources-fail', [this.originalContractsDir]) - throw new Error(msg); - } - - if (shell.test('-e', this.contractsDir)){ - shell.rm('-Rf', this.contractsDir); - } - - if (shell.test('-e', this.artifactsDir)){ - shell.rm('-Rf', this.artifactsDir); - } - } - - /** - * Creates two temporary folders in the cwd and - * copies contract sources to a temp contracts folder prior to their - * instrumentation. This method is useful for plugin APIs that - * consume contracts and build artifacts from configurable locations. - * - * .coverage_contracts/ - * .coverage_artifacts/ - */ - generateStandardEnvironment(){ - shell.mkdir(this.contractsDir); - shell.mkdir(this.artifactsDir); - shell.cp('-Rf', `${this.originalContractsDir}/*`, this.contractsDir); - } /** * Removes coverage build artifacts, kills testrpc. */ - async cleanUp() { - const self = this; - shell.config.silent = true; - shell.rm('-Rf', this.contractsDir); - shell.rm('-Rf', this.artifactsDir); - + async finish() { if (this.server && this.server.close){ - this.ui.report('cleanup'); - await pify(self.server.close)(); + this.ui.report('finish'); + await pify(this.server.close)(); } } // ------------------------------------------ Utils ---------------------------------------------- @@ -316,13 +245,6 @@ class App { // ======== // File I/O // ======== - loadContract(_path){ - return fs.readFileSync(_path).toString(); - } - - saveContract(_path, contract){ - fs.writeFileSync(_path, contract); - } saveCoverage(data){ const covPath = path.join(this.cwd, "coverage.json"); @@ -332,6 +254,7 @@ class App { // ===== // Paths // ===== + // /** * Relativizes path keys so that istanbul report can be read on Windows * @param {Object} map coverage map generated by coverageMap @@ -352,67 +275,13 @@ class App { return absolutePath.split(`/${parentDir}`)[1] } - /** - * Normalizes windows paths - * @param {String} file path - * @return {String} normalized path - */ - platformNeutralPath(file) { - return (isWin) - ? path.resolve(file).split('\\').join('/') - : path.resolve(file); - } - - // ======== - // Skipping - // ======== - /** - * Determines if a file is in a folder marked skippable in a standard environment where - * instrumented files are in their own temporary folder. - * @param {String} file file path - * @return {Boolean} - */ - inSkippedFolder(file){ - let shouldSkip; - const root = `${this.contractsDir}`; - this.skippedFolders.forEach(folderToSkip => { - folderToSkip = `${root}/${folderToSkip}`; - if (file.indexOf(folderToSkip) === 0) - shouldSkip = true; - }); - return shouldSkip; - } - - /** - * Parses the skipFiles option (which also accepts folders) in a standard environment where - * instrumented files are in their own temporary folder. - */ - registerSkippedItems(){ - const root = `${this.contractsDir}`; - this.skippedFolders = this.skipFiles.filter(item => path.extname(item) !== '.sol') - this.skipFiles = this.skipFiles.map(contract => `${root}/${contract}`); - this.skipFiles.push(`${root}/Migrations.sol`); - } - - /** - * Returns true when file should not be instrumented, false otherwise. - * This method should be overwritten if plugin does in-flight instrumentation - * @param {String} file path segment - * @return {Boolean} - */ - shouldSkip(file){ - return this.skipFiles.includes(file) || this.inSkippedFolder(file) - } - // ======= // Logging // ======= + /** * Turn logging off (for CI) * @param {Boolean} isSilent - * - * TODO: logic to toggle on/off (instead of just off) - * */ setLoggingLevel(isSilent) { if (isSilent) this.log = () => {}; @@ -420,4 +289,4 @@ class App { } -module.exports = App; +module.exports = API; diff --git a/lib/ui.js b/lib/ui.js index 4fac6b0..011d109 100644 --- a/lib/ui.js +++ b/lib/ui.js @@ -64,16 +64,12 @@ class AppUI extends UI { 'instr-start': `\n${c.bold('Instrumenting for coverage...')}` + `\n${c.bold('=============================')}\n`, - 'instr-skip': `\n${c.bold('Coverage skipped for:')}` + - `\n${c.bold('=====================')}\n`, - 'instr-item': `${ct} ${args[0]}`, - 'instr-skipped': `${ds} ${c.grey(args[0])}`, 'istanbul': `${ct} ${c.grey('Istanbul reports written to')} ./coverage/ ` + `${c.grey('and')} ./coverage.json`, - 'cleanup': `${ct} ${c.grey('solidity-coverage cleaning up, shutting down ganache server')}`, + 'finish': `${ct} ${c.grey('solidity-coverage cleaning up, shutting down ganache server')}`, 'server': `${ct} ${c.bold('server: ')} ${c.grey(args[0])}`, diff --git a/package.json b/package.json index 2741053..bca3197 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@truffle/provider": "^0.1.17", "chalk": "^2.4.2", "death": "^1.1.0", + "fs-extra": "^8.1.0", "ganache-core-sc": "2.7.0-sc.0", "ghost-testrpc": "^0.0.2", "global-modules": "^2.0.0", @@ -36,7 +37,6 @@ "node-dir": "^0.1.17", "node-emoji": "^1.10.0", "pify": "^4.0.1", - "req-cwd": "^1.0.1", "shelljs": "^0.8.3", "solidity-parser-antlr": "^0.4.7", "web3": "1.2.1", diff --git a/test/units/truffle/errors.js b/test/units/truffle/errors.js index 63720c8..7b56593 100644 --- a/test/units/truffle/errors.js +++ b/test/units/truffle/errors.js @@ -184,7 +184,7 @@ describe('Truffle Plugin: error cases', function() { assert.fail() } catch(err){ assert( - err.message.includes('/Unparseable.sol.'), + err.message.includes('Unparseable.sol.'), `Should throw instrumentation errors with file name: ${err.toString()}` ); diff --git a/test/units/truffle/standard.js b/test/units/truffle/standard.js index 2933a09..17f6726 100644 --- a/test/units/truffle/standard.js +++ b/test/units/truffle/standard.js @@ -25,7 +25,7 @@ describe('Truffle Plugin: standard use cases', function() { afterEach(() => mock.clean()); - it('simple contract: should generate coverage, cleanup & exit(0)', async function(){ + it('simple contract', async function(){ verify.cleanInitialState(); mock.install('Simple', 'simple.js', solcoverConfig); @@ -116,7 +116,7 @@ describe('Truffle Plugin: standard use cases', function() { verify.lineCoverage(expected); }); - it('project skips a folder', async function() { + it('skips a folder', async function() { verify.cleanInitialState(); mock.installFullProject('skipping'); await plugin(truffleConfig); @@ -127,7 +127,7 @@ describe('Truffle Plugin: standard use cases', function() { }]; const missing = [{ - file: mock.pathToContract(truffleConfig, 'ContractB.sol'), + file: mock.pathToContract(truffleConfig, 'skipped-folder/ContractB.sol'), }]; verify.lineCoverage(expected); diff --git a/yarn.lock b/yarn.lock index 3e862d8..8eba17f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3079,6 +3079,15 @@ fs-extra@^7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-minipass@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" @@ -3400,6 +3409,11 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1. resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== +graceful-fs@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02" + integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q== + "graceful-readlink@>= 1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" @@ -5854,20 +5868,6 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -req-cwd@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/req-cwd/-/req-cwd-1.0.1.tgz#0d73aeae9266e697a78f7976019677e76acf0fff" - integrity sha1-DXOurpJm5penj3l2AZZ352rPD/8= - dependencies: - req-from "^1.0.1" - -req-from@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/req-from/-/req-from-1.0.1.tgz#bf81da5147947d32d13b947dc12a58ad4587350e" - integrity sha1-v4HaUUeUfTLRO5R9wSpYrUWHNQ4= - dependencies: - resolve-from "^2.0.0" - request@^2.79.0, request@^2.85.0: version "2.88.0" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" @@ -5923,11 +5923,6 @@ requireg@^0.2.2: rc "~1.2.7" resolve "~1.7.1" -resolve-from@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" - integrity sha1-lICrIOlP+h2egKgEx+oUdhGWa1c= - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"