diff --git a/circle.yml b/circle.yml index 8c76c90..30c8325 100644 --- a/circle.yml +++ b/circle.yml @@ -1,3 +1,8 @@ +machine: + node: + version: 6.9.1 dependencies: pre: - npm install -g truffle + - rm -rf node_modules/ + \ No newline at end of file diff --git a/coverageMap.js b/coverageMap.js new file mode 100644 index 0000000..214ab15 --- /dev/null +++ b/coverageMap.js @@ -0,0 +1,92 @@ + +/** + * This file contains methods that produce a coverage map to pass to instanbul + * from data generated by `instrumentSolidity.js` + */ +const SolidityCoder = require('web3/lib/solidity/coder.js'); +const path = require('path'); + +const lineTopic = 'b8995a65f405d9756b41a334f38d8ff0c93c4934e170d3c1429c3e7ca101014d'; +const functionTopic = 'd4ce765fd23c5cc3660249353d61ecd18ca60549dd62cb9ca350a4244de7b87f'; +const branchTopic = 'd4cf56ed5ba572684f02f889f12ac42d9583c8e3097802060e949bfbb3c1bff5'; +const statementTopic = 'b51abbff580b3a34bbc725f2dc6f736e9d4b45a41293fd0084ad865a31fde0c8'; + +/** + * Converts solcover event data into an object that can be + * be passed to instanbul to produce coverage reports. + * @type {CoverageMap} + */ +module.exports = class CoverageMap { + + constructor() { + this.coverage = {}; + } + + /** + * Initializes a coverage map object for contract instrumented per `info` and located + * at `canonicalContractPath` + * @param {Object} info `info = getIntrumentedVersion(contract, fileName, true)` + * @param {String} canonicalContractPath target file location + * @return {Object} coverage map with all values set to zero + */ + addContract(info, canonicalContractPath) { + this.coverage[canonicalContractPath] = { + l: {}, + path: canonicalContractPath, + s: {}, + b: {}, + f: {}, + fnMap: {}, + statementMap: {}, + branchMap: {}, + }; + + info.runnableLines.forEach((item, idx) => { + this.coverage[canonicalContractPath].l[info.runnableLines[idx]] = 0; + }); + this.coverage[canonicalContractPath].fnMap = info.fnMap; + for (let x = 1; x <= Object.keys(info.fnMap).length; x++) { + this.coverage[canonicalContractPath].f[x] = 0; + } + this.coverage[canonicalContractPath].branchMap = info.branchMap; + for (let x = 1; x <= Object.keys(info.branchMap).length; x++) { + this.coverage[canonicalContractPath].b[x] = [0, 0]; + } + this.coverage[canonicalContractPath].statementMap = info.statementMap; + for (let x = 1; x <= Object.keys(info.statementMap).length; x++) { + this.coverage[canonicalContractPath].s[x] = 0; + } + } + + /** + * Populates an empty coverage map with values derived from an array of events + * fired by instrumented contracts as they are tested + * @param {Array} events + * @param {String} relative path to host contracts eg: './../contracts' + * @return {Object} coverage map. + */ + generate(events, pathPrefix) { + for (let idx = 0; idx < events.length; idx++) { + const event = JSON.parse(events[idx]); + if (event.topics.indexOf(lineTopic) >= 0) { + const data = SolidityCoder.decodeParams(['string', 'uint256'], event.data.replace('0x', '')); + const canonicalContractPath = path.resolve(pathPrefix + path.basename(data[0])); + this.coverage[canonicalContractPath].l[data[1].toNumber()] += 1; + } else if (event.topics.indexOf(functionTopic) >= 0) { + const data = SolidityCoder.decodeParams(['string', 'uint256'], event.data.replace('0x', '')); + const canonicalContractPath = path.resolve(pathPrefix + path.basename(data[0])); + this.coverage[canonicalContractPath].f[data[1].toNumber()] += 1; + } else if (event.topics.indexOf(branchTopic) >= 0) { + const data = SolidityCoder.decodeParams(['string', 'uint256', 'uint256'], event.data.replace('0x', '')); + const canonicalContractPath = path.resolve(pathPrefix + path.basename(data[0])); + this.coverage[canonicalContractPath].b[data[1].toNumber()][data[2].toNumber()] += 1; + } else if (event.topics.indexOf(statementTopic) >= 0) { + const data = SolidityCoder.decodeParams(['string', 'uint256'], event.data.replace('0x', '')); + const canonicalContractPath = path.resolve(pathPrefix + path.basename(data[0])); + this.coverage[canonicalContractPath].s[data[1].toNumber()] += 1; + } + } + return Object.assign({}, this.coverage); + } +}; + diff --git a/instrumentSolidity.js b/instrumentSolidity.js index 305606d..c5127aa 100644 --- a/instrumentSolidity.js +++ b/instrumentSolidity.js @@ -1,9 +1,12 @@ var SolidityParser = require("solidity-parser"); +var preprocessor = require('./preprocessor'); + //var solparse = require("solparse"); var path = require("path"); module.exports = function(contract, fileName, instrumentingActive){ + contract = preprocessor.run(contract); var result = SolidityParser.parse(contract); //var result = solparse.parse(contract); var instrumented = ""; @@ -100,10 +103,8 @@ module.exports = function(contract, fileName, instrumentingActive){ endcol = contract.slice(expressionContent.lastIndexOf('\n'), expression.end).length -1; }else{ endcol = startcol + expressionContent.length -1; - } statementMap[statementId] = {start:{line: startline, column:startcol},end:{line:endline, column:endcol}} - createOrAppendInjectionPoint(expression.start, {type:"statement", statementId: statementId}); } @@ -121,6 +122,8 @@ module.exports = function(contract, fileName, instrumentingActive){ // Is everything before us and after us on this line whitespace? if (contract.slice(lastNewLine, startchar).trim().length===0 && contract.slice(endchar,nextNewLine).replace(';','').trim().length===0){ createOrAppendInjectionPoint(lastNewLine+1,{type:"callEvent"}); + } else if (contract.slice(lastNewLine, startchar).replace('{','').trim().length===0 && contract.slice(endchar,nextNewLine).replace(/[;}]/g,'').trim().length===0){ + createOrAppendInjectionPoint(expression.start,{type:"callEvent"}); } } @@ -149,25 +152,18 @@ module.exports = function(contract, fileName, instrumentingActive){ var startcol = expression.start - contract.slice(0,expression.start).lastIndexOf('\n') -1; //NB locations for if branches in istanbul are zero length and associated with the start of the if. branchMap[branchId] = {line:linecount, type:'if', locations:[{start:{line:startline, column:startcol},end:{line:startline,column:startcol}},{start:{line:startline, column:startcol},end:{line:startline,column:startcol}}]} - if (contract.slice(expression.consequent.start,expression.consequent.end).trim().indexOf('{')===0){ + if (expression.consequent.type === "BlockStatement"){ createOrAppendInjectionPoint(expression.consequent.start+1,{type: "callBranchEvent", branchId: branchId, locationIdx: 0} ) - }else{ - createOrAppendInjectionPoint(expression.consequent.start,{type: "callBranchEvent", branchId: branchId, locationIdx: 0, openBracket:true} ) - createOrAppendInjectionPoint(expression.consequent.end, {type:"closeBracketStart"}); } - if (expression.alternate && expression.alternate.type==='IfStatement'){ createOrAppendInjectionPoint(expression.alternate.start, {type: "callBranchEvent", branchId: branchId, locationIdx:1, openBracket: true}) createOrAppendInjectionPoint(expression.alternate.end, {type:"closeBracketEnd"}); //It should get instrumented when we parse it - } else if (expression.alternate && contract.slice(expression.alternate.start,expression.alternate.end).trim().indexOf('{')===0){ + } else if (expression.alternate && expression.alternate.type === "BlockStatement"){ createOrAppendInjectionPoint(expression.alternate.start+1, {type: "callBranchEvent", branchId: branchId, locationIdx: 1}) - } else if (expression.alternate){ - createOrAppendInjectionPoint(expression.alternate.start, {type: "callBranchEvent", branchId: branchId, locationIdx: 1}) } else { createOrAppendInjectionPoint(expression.consequent.end, {type: "callEmptyBranchEvent", branchId: branchId, locationIdx: 1}); } - } parse["AssignmentExpression"] = function (expression, instrument){ @@ -368,6 +364,16 @@ module.exports = function(contract, fileName, instrumentingActive){ } } + parse["WhileStatement"] = function(expression, instrument){ + if (instrument){instrumentStatement(expression)} + parse[expression.body.type](expression.body, instrument); + } + + parse["ForStatement"] = function(expression, instrument){ + if (instrument){instrumentStatement(expression)} + parse[expression.body.type](expression.body, instrument); + } + parse["StructDeclaration"] = function(expression, instrument){ } @@ -407,12 +413,6 @@ module.exports = function(contract, fileName, instrumentingActive){ parse["DoWhileStatement"] = function(expression, instrument){ } - parse["WhileStatement"] = function(expression, instrument){ - } - - parse["ForStatement"] = function(expression, instrument){ - } - parse["ForInStatement"] = function(expression, instrument){ } diff --git a/package.json b/package.json index a73194b..4239e80 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,15 @@ "ethereumjs-testrpc": "^3.0.3", "istanbul": "^0.4.5", "shelljs": "^0.7.4", + "sol-explore": "^1.6.2", "solidity-parser": "git+https://github.com/ConsenSys/solidity-parser.git#master" }, "devDependencies": { + "crypto-js": "^3.1.9-1", + "ethereumjs-account": "^2.0.4", + "ethereumjs-tx": "^1.2.2", + "ethereumjs-util": "^5.0.1", + "merkle-patricia-tree": "^2.1.2", "mocha": "^3.1.0", "solc": "^0.4.6" } diff --git a/preprocessor.js b/preprocessor.js new file mode 100644 index 0000000..2a109cc --- /dev/null +++ b/preprocessor.js @@ -0,0 +1,56 @@ +const SolExplore = require('sol-explore'); +const SolidityParser = require('solidity-parser'); + +/** + * Splices enclosing brackets into `contract` around `expression`; + * @param {String} contract solidity code + * @param {Object} node AST node to bracket + * @return {String} contract + */ +function blockWrap(contract, expression) { + return contract.slice(0, expression.start) + '{' + contract.slice(expression.start, expression.end) + '}' + contract.slice(expression.end); +} + +/** + * Locates unbracketed singleton statements attached to if, else, for and while statements + * and brackets them. Instrumenter needs to inject events at these locations and having + * them pre-bracketed simplifies the process. Each time a modification is made the contract + * is passed back to the parser and re-walked because all the starts and ends get shifted. + * @param {String} contract solidity code + * @return {String} contract + */ +module.exports.run = function r(contract) { + let keepRunning = true; + + while (keepRunning) { + const ast = SolidityParser.parse(contract); + keepRunning = false; + SolExplore.traverse(ast, { + enter(node, parent) { + // If consequents + if (node.type === 'IfStatement' && node.consequent.type !== 'BlockStatement') { + contract = blockWrap(contract, node.consequent); + keepRunning = true; + this.stopTraversal(); + // If alternates + } else if ( + node.type === 'IfStatement' && + node.alternate && + node.alternate.type !== 'IfStatement' && + node.alternate.type !== 'BlockStatement') { + contract = blockWrap(contract, node.alternate); + keepRunning = true; + this.stopTraversal(); + // Loops + } else if ( + (node.type === 'ForStatement' || node.type === 'WhileStatement') && + node.body.type !== 'BlockStatement') { + contract = blockWrap(contract, node.body); + keepRunning = true; + this.stopTraversal(); + } + }, + }); + } + return contract; +}; \ No newline at end of file diff --git a/runCoveredTests.js b/runCoveredTests.js index 67b1229..1fbf9aa 100644 --- a/runCoveredTests.js +++ b/runCoveredTests.js @@ -3,10 +3,11 @@ var web3 = new Web3(new Web3.providers.HttpProvider("http://localhost:8545")); var shell = require('shelljs'); var SolidityCoder = require("web3/lib/solidity/coder.js"); -var coverage = {}; var fs = require('fs'); var path = require('path'); var getInstrumentedVersion = require('./instrumentSolidity.js'); +var CoverageMap = require('./coverageMap.js'); +var coverage = new CoverageMap(); var childprocess = require('child_process'); @@ -34,22 +35,7 @@ shell.ls('./../originalContracts/*.sol').forEach(function(file) { var instrumentedContractInfo = getInstrumentedVersion(contract, fileName, true); fs.writeFileSync('./../contracts/' + path.basename(file), instrumentedContractInfo.contract); var canonicalContractPath = path.resolve('./../originalContracts/' + path.basename(file)); - coverage[canonicalContractPath] = { "l": {}, "path": canonicalContractPath, "s": {}, "b": {}, "f": {}, "fnMap": {}, "statementMap": {}, "branchMap": {} }; - for (idx in instrumentedContractInfo.runnableLines) { - coverage[canonicalContractPath]["l"][instrumentedContractInfo.runnableLines[idx]] = 0; - } - coverage[canonicalContractPath].fnMap = instrumentedContractInfo.fnMap; - for (x=1; x<=Object.keys(instrumentedContractInfo.fnMap).length; x++ ){ - coverage[canonicalContractPath]["f"][x] = 0; - } - coverage[canonicalContractPath].branchMap = instrumentedContractInfo.branchMap; - for (x=1; x<=Object.keys(instrumentedContractInfo.branchMap).length; x++ ){ - coverage[canonicalContractPath]["b"][x] = [0,0]; - } - coverage[canonicalContractPath].statementMap= instrumentedContractInfo.statementMap; - for (x=1; x<=Object.keys(instrumentedContractInfo.statementMap).length; x++ ){ - coverage[canonicalContractPath]["s"][x] = 0; - } + coverage.addContract(instrumentedContractInfo, canonicalContractPath); } }); shell.cp("./../originalContracts/Migrations.sol", "./../contracts/Migrations.sol"); @@ -58,30 +44,12 @@ shell.rm('./allFiredEvents'); //Delete previous results shell.exec('truffle test --network coverage'); events = fs.readFileSync('./allFiredEvents').toString().split('\n') -for (idx=0; idx < events.length-1; idx++){ - //The limit here isn't a bug - there is an empty line at the end of this file, so we don't - //want to go to the very end of the array. - var event = JSON.parse(events[idx]); - if (event.topics.indexOf("b8995a65f405d9756b41a334f38d8ff0c93c4934e170d3c1429c3e7ca101014d") >= 0) { - var data = SolidityCoder.decodeParams(["string", "uint256"], event.data.replace("0x", "")); - var canonicalContractPath = path.resolve('./../originalContracts/' + path.basename(data[0])); - coverage[canonicalContractPath]["l"][data[1].toNumber()] += 1; - }else if(event.topics.indexOf("d4ce765fd23c5cc3660249353d61ecd18ca60549dd62cb9ca350a4244de7b87f")>=0){ - var data = SolidityCoder.decodeParams(["string", "uint256"], event.data.replace("0x", "")); - var canonicalContractPath = path.resolve('./../originalContracts/' + path.basename(data[0])); - coverage[canonicalContractPath]["f"][data[1].toNumber()] += 1; - }else if(event.topics.indexOf("d4cf56ed5ba572684f02f889f12ac42d9583c8e3097802060e949bfbb3c1bff5")>=0){ - var data = SolidityCoder.decodeParams(["string", "uint256", "uint256"], event.data.replace("0x", "")); - var canonicalContractPath = path.resolve('./../originalContracts/' + path.basename(data[0])); - coverage[canonicalContractPath]["b"][data[1].toNumber()][data[2].toNumber()] += 1; - }else if(event.topics.indexOf("b51abbff580b3a34bbc725f2dc6f736e9d4b45a41293fd0084ad865a31fde0c8")>=0){ - var data = SolidityCoder.decodeParams(["string","uint256"], event.data.replace("0x", "")); - var canonicalContractPath = path.resolve('./../originalContracts/' + path.basename(data[0])); - coverage[canonicalContractPath]["s"][data[1].toNumber()]+= 1; - } -} +events.pop(); +//The pop here isn't a bug - there is an empty line at the end of this file, so we +//don't want to include it as an event. +coverage.generate(events, './../originalContracts/'); -fs.writeFileSync('./coverage.json', JSON.stringify(coverage)); +fs.writeFileSync('./coverage.json', JSON.stringify(coverage.coverage)); shell.exec("./node_modules/istanbul/lib/cli.js report html") testrpcProcess.kill(); diff --git a/test/if.js b/test/if.js index 636faea..b15f2d9 100644 --- a/test/if.js +++ b/test/if.js @@ -1,44 +1,149 @@ -var solc = require('solc'); -var getInstrumentedVersion = require('./../instrumentSolidity.js'); -var util = require('./util/util.js') +const solc = require('solc'); +const path = require('path'); +const getInstrumentedVersion = require('./../instrumentSolidity.js'); +const util = require('./util/util.js'); +const CoverageMap = require('./../coverageMap'); +const vm = require('./util/vm'); +const assert = require('assert'); -/** - * NB: passing '1' to solc as an option activates the optimiser - */ describe('if, else, and else if statements', function(){ - it('should compile after instrumenting else statements with brackets',function(){ - var contract = util.getCode('if/else-with-brackets.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); - }) + const fileName = 'test.sol'; + const filePath = path.resolve('./test.sol'); + const pathPrefix = './'; + + it('should cover an if statement with a bracketed consequent', (done) => { + const contract = util.getCode('if/if-with-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a(1) => if (x == 1) { x = 3; } + vm.execute(info.contract, 'a', [1]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1}); + assert.deepEqual(mapping[filePath].b, {1: [1, 0]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }); + }); - it('should compile after instrumenting else statements without brackets',function(){ - var contract = util.getCode('if/else-without-brackets.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); + // Runs: a(1) => if (x == 1) x = 2; + it('should cover an unbracketed if consequent (single line)',function(done){ + const contract = util.getCode('if/if-no-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Same results as previous test + vm.execute(info.contract, 'a', [1]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1}); + assert.deepEqual(mapping[filePath].b, {1: [1, 0]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }).catch(err => {console.log(err); done() }) }) - it('should compile after instrumenting if statements with no brackets',function(){ - var contract = util.getCode('if/if-no-brackets.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); + it('should cover an if statement with multiline bracketed consequent', (done) => { + const contract = util.getCode('if/if-with-brackets-multiline.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a(1) => if (x == 1){\n x = 3; } + vm.execute(info.contract, 'a', [1]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 1}); + assert.deepEqual(mapping[filePath].b, {1: [1, 0]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }); + }); + + // Runs: a(1) => if (x == 1)\n x = 3; + it('should cover an unbracketed if consequent (multi-line)', function(done){ + const contract = util.getCode('if/if-no-brackets-multiline.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + // Same results as previous test + vm.execute(info.contract, 'a', [1]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 1}); + assert.deepEqual(mapping[filePath].b, {1: [1, 0]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) }) - it('should compile after instrumenting if statements with brackets',function(){ - var contract = util.getCode('if/if-with-brackets.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); + it('should cover a simple if statement with a failing condition', (done) => { + const contract = util.getCode('if/if-with-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a(2) => if (x == 1) { x = 3; } + vm.execute(info.contract, 'a', [2]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1}); + assert.deepEqual(mapping[filePath].b, {1: [0, 1]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 0}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }).catch(err => {console.log(err); done() }) + }); + + // Runs: a(2) => if (x == 1){\n throw;\n }else{\n x = 5; \n} + it('should cover an if statement with a bracketed alternate', (done) => { + const contract = util.getCode('if/else-with-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + vm.execute(info.contract, 'a', [2]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 0, 8: 1}); + assert.deepEqual(mapping[filePath].b, {1: [0, 1]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 0, 3: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) + }); + + it('should cover an if statement with an unbracketed alternate',function(done){ + const contract = util.getCode('if/else-without-brackets.sol'); + const info = getInstrumentedVersion(contract, "test.sol", true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + vm.execute(info.contract, 'a', [2]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 0, 8: 1}); + assert.deepEqual(mapping[filePath].b, {1: [0, 1]}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 0, 3: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) }) - it('should compile after instrumenting nested if statements with missing else statements',function(){ - var contract = util.getCode('if/nested-if-missing-else.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); + it('should cover nested if statements with missing else statements',function(done){ + const contract = util.getCode('if/nested-if-missing-else.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + vm.execute(info.contract, 'a', [2, 3, 3]).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 7: 1}); + assert.deepEqual(mapping[filePath].b, { '1': [ 0, 1 ], '2': [ 1, 0 ], '3': [ 1, 0 ] }); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) }) }) \ No newline at end of file diff --git a/test/loops.js b/test/loops.js new file mode 100644 index 0000000..708197c --- /dev/null +++ b/test/loops.js @@ -0,0 +1,82 @@ +const solc = require('solc'); +const path = require('path'); +const getInstrumentedVersion = require('./../instrumentSolidity.js'); +const util = require('./util/util.js'); +const CoverageMap = require('./../coverageMap'); +const vm = require('./util/vm'); +const assert = require('assert'); + +describe('for and while statements', function(){ + + const fileName = 'test.sol'; + const filePath = path.resolve('./test.sol'); + const pathPrefix = './'; + + it('should cover a for statement with a bracketed body (multiline)', (done) => { + const contract = util.getCode('loops/for-with-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a() => for(var x = 1; x < 10; x++){\n sha3(x);\n } + vm.execute(info.contract, 'a', []).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 10}); + assert.deepEqual(mapping[filePath].b, {}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 10}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) + }); + + it('should cover a for statement with an unbracketed body', (done) => { + const contract = util.getCode('loops/for-no-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a() => for(var x = 1; x < 10; x++)\n sha3(x);\n + vm.execute(info.contract, 'a', []).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 10}); + assert.deepEqual(mapping[filePath].b, {}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 10}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) + }); + + it('should cover a while statement with an bracketed body (multiline)', (done) => { + const contract = util.getCode('loops/while-with-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a() => var t = true;\n while(t){\n t = false;\n } + vm.execute(info.contract, 'a', []).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 1, 7: 1}); + assert.deepEqual(mapping[filePath].b, {}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1, 3: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) + }); + + it('should cover a while statement with an unbracketed body (multiline)', (done) => { + const contract = util.getCode('loops/while-no-brackets.sol'); + const info = getInstrumentedVersion(contract, fileName, true); + const coverage = new CoverageMap(); + coverage.addContract(info, filePath); + + // Runs: a() => var t = true;\n while(t)\n t = false;\n + vm.execute(info.contract, 'a', []).then(events => { + const mapping = coverage.generate(events, pathPrefix); + assert.deepEqual(mapping[filePath].l, {5: 1, 6: 1, 7: 1}); + assert.deepEqual(mapping[filePath].b, {}); + assert.deepEqual(mapping[filePath].s, {1: 1, 2: 1, 3: 1}); + assert.deepEqual(mapping[filePath].f, {1: 1}); + done(); + }) + }); +}) \ No newline at end of file diff --git a/test/sources/statements/if-consequent-no-brackets-multiline.sol b/test/sources/if/if-no-brackets-multiline.sol similarity index 82% rename from test/sources/statements/if-consequent-no-brackets-multiline.sol rename to test/sources/if/if-no-brackets-multiline.sol index 1d817a8..92abce1 100644 --- a/test/sources/statements/if-consequent-no-brackets-multiline.sol +++ b/test/sources/if/if-no-brackets-multiline.sol @@ -3,6 +3,6 @@ pragma solidity ^0.4.3; contract Test { function a(uint x) { if (x == 1) - throw; + x = 2; } } \ No newline at end of file diff --git a/test/sources/if/if-no-brackets.sol b/test/sources/if/if-no-brackets.sol index 01f9211..e72c449 100644 --- a/test/sources/if/if-no-brackets.sol +++ b/test/sources/if/if-no-brackets.sol @@ -2,6 +2,6 @@ pragma solidity ^0.4.3; contract Test { function a(uint x) { - if (x == 1) throw; + if (x == 1) x = 2; } } \ No newline at end of file diff --git a/test/sources/if/if-with-brackets-multiline.sol b/test/sources/if/if-with-brackets-multiline.sol new file mode 100644 index 0000000..81aa280 --- /dev/null +++ b/test/sources/if/if-with-brackets-multiline.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.4.3; + +contract Test { + function a(uint x) { + if (x == 1) { + x = 3; + } + } +} \ No newline at end of file diff --git a/test/sources/if/if-with-brackets.sol b/test/sources/if/if-with-brackets.sol index 89000e2..4f36baf 100644 --- a/test/sources/if/if-with-brackets.sol +++ b/test/sources/if/if-with-brackets.sol @@ -2,6 +2,6 @@ pragma solidity ^0.4.3; contract Test { function a(uint x) { - if (x == 1) { throw; } + if (x == 1) {x = 3;} } } \ No newline at end of file diff --git a/test/sources/loops/for-no-brackets.sol b/test/sources/loops/for-no-brackets.sol new file mode 100644 index 0000000..8fa9dbd --- /dev/null +++ b/test/sources/loops/for-no-brackets.sol @@ -0,0 +1,8 @@ +pragma solidity ^0.4.3; + +contract Test { + function a() { + for(var x = 0; x < 10; x++) + sha3(x); + } +} \ No newline at end of file diff --git a/test/sources/loops/for-with-brackets.sol b/test/sources/loops/for-with-brackets.sol new file mode 100644 index 0000000..197ebe3 --- /dev/null +++ b/test/sources/loops/for-with-brackets.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.4.3; + +contract Test { + function a() { + for(var x = 0; x < 10; x++){ + sha3(x); + } + } +} \ No newline at end of file diff --git a/test/sources/loops/while-no-brackets.sol b/test/sources/loops/while-no-brackets.sol new file mode 100644 index 0000000..3ef0def --- /dev/null +++ b/test/sources/loops/while-no-brackets.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.4.3; + +contract Test { + function a() { + var t = true; + while(t) + t = false; + } +} \ No newline at end of file diff --git a/test/sources/loops/while-with-brackets.sol b/test/sources/loops/while-with-brackets.sol new file mode 100644 index 0000000..9acf9a5 --- /dev/null +++ b/test/sources/loops/while-with-brackets.sol @@ -0,0 +1,10 @@ +pragma solidity ^0.4.3; + +contract Test { + function a() { + var t = true; + while(t){ + t = false; + } + } +} \ No newline at end of file diff --git a/test/statements.js b/test/statements.js index 5f0580d..4e3bf83 100644 --- a/test/statements.js +++ b/test/statements.js @@ -35,11 +35,4 @@ describe('generic statements', function(){ var output = solc.compile(info.contract, 1); util.report(output.errors); }) - - it('should compile after instrumenting a statement that is an unbracketed "if" consequent (multi-line)', function(){ - var contract = util.getCode('statements/if-consequent-no-brackets-multiline.sol'); - var info = getInstrumentedVersion(contract, "test.sol", true); - var output = solc.compile(info.contract, 1); - util.report(output.errors); - }) }) diff --git a/test/util/vm.js b/test/util/vm.js new file mode 100644 index 0000000..417afae --- /dev/null +++ b/test/util/vm.js @@ -0,0 +1,140 @@ +const solc = require('solc'); +const path = require('path'); +const VM = require('ethereumjs-vm'); +const Account = require('ethereumjs-account'); +const Transaction = require('ethereumjs-tx'); +const utils = require('ethereumjs-util'); +const CryptoJS = require('crypto-js'); +const Trie = require('merkle-patricia-tree'); +const coder = require('web3/lib/solidity/coder.js'); + +// Don't use this address for anything, obviously! +const secretKey = 'e81cb653c260ee12c72ec8750e6bfd8a4dc2c3d7e3ede68dd2f150d0a67263d8'; +const accountAddress = new Buffer('7caf6f9bc8b3ba5c7824f934c826bd6dc38c8467', 'hex'); + +/** + * Encodes function data + * Source: consensys/eth-lightwallet/lib/txutils.js (line 18) + */ +function encodeFunctionTxData(functionName, types, args) { + const fullName = functionName + '(' + types.join() + ')'; + const signature = CryptoJS.SHA3(fullName, {outputLength: 256}).toString(CryptoJS.enc.Hex).slice(0, 8); + const dataHex = signature + coder.encodeParams(types, args); + return '0x' + dataHex; +} + +/** + * Extracts types from abi + * Source: consensys/eth-lightwallet/lib/txutils.js (line 27) + */ +function getTypesFromAbi(abi, functionName) { + function matchesFunctionName(json) { + return (json.name === functionName && json.type === 'function'); + } + function getTypes(json) { + return json.type; + } + const funcJson = abi.filter(matchesFunctionName)[0]; + return (funcJson.inputs).map(getTypes); +} + +/** + * Retrieves abi for contract + * Source: raineorshine/eth-new-contract/src/index.js (line 8) + * @param {String} source solidity contract + * @param {Object} compilation compiled `source` + * @return {Object} abi + */ +function getAbi(source, compilation){ + const contractNameMatch = source.match(/(?:contract|library)\s([^\s]*)\s*{/) + if(!contractNameMatch) { + throw new Error('Could not parse contract name from source.') + } + const contractName = contractNameMatch[1] + return JSON.parse(compilation.contracts[contractName].interface) +} + +/** + * Creates, funds and publishes account to Trie + */ +function createAccount(trie) { + const account = new Account(); + account.balance = 'f00000000000000000'; + trie.put(accountAddress, account.serialize()); +} + +/** + * Deploys contract represented by `code` + * @param {String} code contract bytecode + */ + +function deploy(vm, code) { + const tx = new Transaction({gasPrice: '1', gasLimit: 'ffffff', data: code,}); + tx.sign(new Buffer(secretKey, 'hex')); + + return new Promise((resolve, reject) => { + vm.runTx({tx: tx}, (err, results) => { + (err) + ? reject(err) + : resolve(results.createdAddress); + }); + }); +} + +/** + * Invokes `functionName` with `args` on contract at `address`. Tx construction logic + * is poached from consensys/eth-lightwallet/lib/txutils:functionTx + * @param {Array} abi contract abi + * @param {String} address deployed contract to invoke method on + * @param {String} functionName method to invoke + * @param {Array} args functionName's arguments + * @return {Promise} resolves array of logged events + */ +function callMethod(vm, abi, address, functionName, args) { + + const types = getTypesFromAbi(abi, functionName); + const txData = encodeFunctionTxData(functionName, types, args); + const options = { + gasPrice: '0x1', + gasLimit: '0xffffff', + to: utils.bufferToHex(address), + data: txData, + nonce: '0x1', + }; + + let tx = new Transaction(options); + tx.sign(new Buffer(secretKey, 'hex')); + + return new Promise((resolve, reject) => { + vm.runTx({tx: tx}, (err, results) => { + let seenEvents = []; + results.vm.runState.logs.map(log => { + const toWrite = {}; + toWrite.address = log[0].toString('hex'); + toWrite.topics = log[1].map(x => x.toString('hex')); + toWrite.data = log[2].toString('hex'); + seenEvents.push(JSON.stringify(toWrite)); + }); + resolve(seenEvents); + }); + }); +} + +/** + * Runs method `functionName` with parameters `args` on contract. Resolves a + * CR delimited list of logged events. + * @param {String} contract solidity to compile + * @param {String} functionName method to invoke on contract + * @param {Array} args parameter values to pass to method + * @return {Promise} resolves array of logged events. + */ +module.exports.execute = function ex(contract, functionName, args) { + const output = solc.compile(contract, 1); + const code = new Buffer(output.contracts.Test.bytecode, 'hex'); + const abi = getAbi(contract, output); + const stateTrie = new Trie(); + const vm = new VM({ state: stateTrie }); + + createAccount(stateTrie); + return deploy(vm, code).then(address => callMethod(vm, abi, address, functionName, args)); +};