diff --git a/addon/.eslintrc.js b/addon/.eslintrc.js new file mode 100644 index 00000000..fbfc3640 --- /dev/null +++ b/addon/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module' + }, + extends: 'eslint:recommended', + env: { + browser: true + }, + rules: { + } +}; diff --git a/addon/helpers/ember-cli-code-coverage-increment.js b/addon/helpers/ember-cli-code-coverage-increment.js new file mode 100644 index 00000000..b2ccec27 --- /dev/null +++ b/addon/helpers/ember-cli-code-coverage-increment.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +export function emberCliCodeCoverageIncrement(params, hash) { + let { path, statement, branch, condition } = hash; + + if (statement) { + window.__coverage__[path].s[statement]++; + } + + if (branch && condition) { + window.__coverage__[path].b[branch][condition]++; + } +} + +export default Ember.Helper.helper(emberCliCodeCoverageIncrement); diff --git a/addon/helpers/ember-cli-code-coverage-register.js b/addon/helpers/ember-cli-code-coverage-register.js new file mode 100644 index 00000000..2e5ee2b1 --- /dev/null +++ b/addon/helpers/ember-cli-code-coverage-register.js @@ -0,0 +1,8 @@ +import Ember from 'ember'; + +export function emberCliCodeCoverageRegister([rawData]) { + let coverageData = JSON.parse(rawData); + window.__coverage__[coverageData.path] = coverageData; +} + +export default Ember.Helper.helper(emberCliCodeCoverageRegister); diff --git a/app/helpers/ember-cli-code-coverage-increment.js b/app/helpers/ember-cli-code-coverage-increment.js new file mode 100644 index 00000000..49fea3b2 --- /dev/null +++ b/app/helpers/ember-cli-code-coverage-increment.js @@ -0,0 +1 @@ +export { default, emberCliCodeCoverageIncrement } from 'ember-cli-code-coverage/helpers/ember-cli-code-coverage-increment'; diff --git a/app/helpers/ember-cli-code-coverage-register.js b/app/helpers/ember-cli-code-coverage-register.js new file mode 100644 index 00000000..f92ea09c --- /dev/null +++ b/app/helpers/ember-cli-code-coverage-register.js @@ -0,0 +1 @@ +export { default, emberCliCodeCoverageRegister } from 'ember-cli-code-coverage/helpers/ember-cli-code-coverage-register'; diff --git a/index.js b/index.js index 6c8fc0c9..52ef5feb 100644 --- a/index.js +++ b/index.js @@ -43,6 +43,23 @@ module.exports = { fileLookup: null, // Ember Methods + setupPreprocessorRegistry: function(type, registry) { + if (!this._isCoverageEnabled()) { return; } + + const buildTemplateInstrumenter = require('./lib/template-instrumenter'); + let TemplateInstrumenter = buildTemplateInstrumenter( + this._parentName(), + this.parent.root, + this.registry.extensionsForType('template'), + this.project.isEmberCLIAddon() + ); + + registry.add('htmlbars-ast-plugin', { + name: "template-instrumenter", + plugin: TemplateInstrumenter, + baseDir: __dirname + }); + }, included: function(appOrAddon) { this._super.included.apply(this, arguments); @@ -204,6 +221,7 @@ module.exports = { return walkSync(dir, { directories: false, globs }).map(file => { const postfix = hasEmberCliTypescript ? file : file.replace(EXT_RE, '.js'); const module = prefix + '/' + postfix; + this.fileLookup[module] = path.join(dirname, file); return module; }); diff --git a/lib/template-instrumenter.js b/lib/template-instrumenter.js new file mode 100644 index 00000000..dacc84cd --- /dev/null +++ b/lib/template-instrumenter.js @@ -0,0 +1,226 @@ +'use strict'; + +const LINE_ENDINGS = /(?:\r\n?|\n)/; +require('string.prototype.startswith'); +const fixPath = require('./coverage-instrumenter').fixPath; + +module.exports = function(appName, appRoot, templateExtensions, isAddon) { + return class IstanbulInstrumenter { + constructor(options) { + + this.options = options; + + let moduleName = options.meta.moduleName; + let relativePath = moduleName && fixPath(moduleName, appName, appRoot, templateExtensions, isAddon); + + this.relativePath = relativePath; + + this.coverageData = { + path: this.relativePath, + s: { }, + b: { }, + f: { }, + fnMap: { }, + statementMap: { }, + branchMap: { }, + code: [ ] + }; + + this._currentStatement = 0; + this._currentBranch = 0; + + if (options.contents) { + this.coverageData.code = options.contents.split(LINE_ENDINGS); + } + } + + shouldInstrument() { + let relativePath = this.relativePath; + + return !relativePath || relativePath.startsWith('app') || relativePath.startsWith('addon'); + } + + currentContainer() { + return this._containerStack[this._containerStack.length - 1]; + } + + insertHelper(container, node, hash) { + let children = container.body || container.children; + let index = children.indexOf(node); + let b = this.syntax.builders; + + hash.pairs.push( + b.pair('path', b.string(this.relativePath)) + ); + + let helper = b.mustache( + b.path('ember-cli-code-coverage-increment'), + null, + hash + ); + helper.isCoverageHelper = true; + + container._statementsToInsert = container._statementsToInsert || []; + container._statementsToInsert.unshift({ + helper, + index + }); + } + + insertStatementHelper(node) { + let b = this.syntax.builders; + + let hash = b.hash([ + b.pair('statement', b.string(this._currentStatement)) + ]); + this.insertHelper(this.currentContainer(), node, hash); + } + + insertBranchHelper(container, node, condition) { + let b = this.syntax.builders; + + let hash = b.hash([ + b.pair('branch', b.string(this._currentBranch)), + b.pair('condition', b.string(condition)) + ]); + + this.insertHelper(container, node, hash); + } + + processStatementsToInsert(node) { + if (node._statementsToInsert) { + node._statementsToInsert.forEach((statement) => { + let { helper, index } = statement; + + let children = node.children || node.body; + children.splice(index, 0, helper); + }); + } + } + + handleBlock(node) { + // cannot process blocks without a loc + if (!node.loc) { + return; + } + + if (node.isCoverageHelper) { return; } + if (this.currentContainer()._ignoreCoverage) { return; } + + this.handleStatement(node); + + this._currentBranch++; + this.coverageData.b[this._currentBranch] = [0,0]; + this.coverageData.branchMap[this._currentBranch] = { + start: { line: node.loc.start.line, column: node.loc.start.column }, + end: { line: node.loc.end.line, column: node.loc.end.column }, + }; + + if (node.type === 'BlockStatement') { + this.insertBranchHelper(node.program, node); + } + } + + handleStatement(node) { + if (node.type === 'TextNode' && node.chars.trim() === '') { + return; + } + + if (node.isCoverageHelper) { return; } + if (this.currentContainer()._ignoreCoverage) { return; } + + // cannot process statements without a loc + if (!node.loc) { + return; + } + + if (node.loc.start.line == null) { + return; + } + + this._currentStatement++; + this.coverageData.s[this._currentStatement] = 0; + this.coverageData.statementMap[this._currentStatement] = { + start: { + line: node.loc.start.line, + column: node.loc.start.column + }, + end: { + line: node.loc && node.loc.end.line, + column: node.loc && node.loc.end.column + }, + }; + + this.insertStatementHelper(node); + } + + transform(ast) { + if (!this.shouldInstrument()) { + return; + } + + let handleBlock = { + enter: (node) => { + this.handleBlock(node); + this._containerStack.push(node); + }, + exit: (node) => { + this._containerStack.pop(); + this.processStatementsToInsert(node); + } + }; + + let handleStatement = (node) => this.handleStatement(node); + + let b = this.syntax.builders; + + this.syntax.traverse(ast, { + Program: { + enter: (node) => { + if (!this._topLevelProgram) { + this._topLevelProgram = node; + this._containerStack = [node]; + } else { + this._containerStack.push(node); + } + }, + exit: (node) => { + this.processStatementsToInsert(node); + if (node === this._topLevelProgram) { + let helper = b.mustache( + b.path('ember-cli-code-coverage-register'), + [ + b.string(JSON.stringify(this.coverageData)) + ] + ); + helper.isCoverageHelper = true; + + node.body.unshift(helper); + } else { + this._containerStack.pop(); + } + }, + }, + + ElementNode: handleBlock, + BlockStatement: handleBlock, + MustacheStatement: handleStatement, + TextNode: handleStatement, + + AttrNode: { + enter: (node) => { + this._containerStack.push(node); + // cannot properly inject helpers into AttrNode positions + node._ignoreCoverage = true; + }, + + exit: () => { + this._containerStack.pop(); + } + } + }); + + return ast; + } + }; +}; diff --git a/test/unit/index-test.js b/test/unit/index-test.js index 9fa2956b..50f9f333 100644 --- a/test/unit/index-test.js +++ b/test/unit/index-test.js @@ -516,4 +516,4 @@ describe('index.js', function() { }); -}); \ No newline at end of file +}); diff --git a/tests/unit/helpers/ember-cli-code-coverage-increment-test.js b/tests/unit/helpers/ember-cli-code-coverage-increment-test.js new file mode 100644 index 00000000..09a2506f --- /dev/null +++ b/tests/unit/helpers/ember-cli-code-coverage-increment-test.js @@ -0,0 +1,61 @@ +import { emberCliCodeCoverageIncrement } from 'dummy/helpers/ember-cli-code-coverage-increment'; +import { module, test } from 'qunit'; + +const ORIGINAL_COVERAGE = window.__coverage__; + +function registerFile(path) { + window.__coverage__[path] = { + "path": path, + "s": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0 + }, + "b": { + "1": [0, 0], + "2": [0, 0] + }, + "f": { }, + "fnMap": { }, + "statementMap": { + // not needed for testing + }, + "branchMap": { + // not needed for testing + }, + "code": [ + // not needed for testing + ] + }; +} + +module('Unit | Helper | ember cli code coverage increment', { + beforeEach() { + window.__coverage__ = {}; + }, + + afterEach() { + window.__coverage__ = ORIGINAL_COVERAGE; + } +}); + +test('it increments the given statement', function(assert) { + let path = 'app/templates/foo'; + registerFile(path); + + emberCliCodeCoverageIncrement([], { path, statement: "1" }); + + assert.equal(window.__coverage__[path].s["1"], 1, 'statement was incremented'); +}); + +test('it increments the given branch', function(assert) { + let path = 'app/templates/foo'; + registerFile(path); + + emberCliCodeCoverageIncrement([], { path, branch: '1', condition: '0' }); + + assert.equal(window.__coverage__[path].b[1][0], 1, 'branch was incremented'); +}); diff --git a/tests/unit/helpers/ember-cli-code-coverage-register-test.js b/tests/unit/helpers/ember-cli-code-coverage-register-test.js new file mode 100644 index 00000000..dfe2deca --- /dev/null +++ b/tests/unit/helpers/ember-cli-code-coverage-register-test.js @@ -0,0 +1,41 @@ +import { emberCliCodeCoverageRegister } from 'dummy/helpers/ember-cli-code-coverage-register'; +import { module, test } from 'qunit'; + +const ORIGINAL_COVERAGE = window.__coverage__; + +module('Unit | Helper | ember cli code coverage register', { + beforeEach() { + window.__coverage__ = {}; + this.fileData = { + "path": 'app/templates/foo', + "s": { + "1": 0, + "2": 0 + }, + "b": { + "1": [0, 0], + "2": [0, 0] + }, + "f": { }, + "fnMap": { }, + "statementMap": {}, + "branchMap": {}, + "code": [] + }; + }, + + afterEach() { + window.__coverage__ = ORIGINAL_COVERAGE; + } +}); + +// Replace this with your real tests. +test('registers the given JSON data for the path', function(assert) { + emberCliCodeCoverageRegister([JSON.stringify(this.fileData)]); + + assert.deepEqual( + window.__coverage__[this.fileData.path], + this.fileData, + 'registered matches' + ); +});