diff --git a/.jshintrc b/.jshintrc index fe86382..300d6de 100644 --- a/.jshintrc +++ b/.jshintrc @@ -34,6 +34,13 @@ "define": true, "require": true, "Chromath": false, - "setImmediate": true + "setImmediate": true, + "expect": true, + "it": true, + "describe": true, + "sinon": true, + "module": true, + "beforeEach": true, + "inject": true } } diff --git a/Gruntfile.js b/Gruntfile.js index f3ca1e0..025733b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -40,11 +40,13 @@ module.exports = function(grunt) { babel: { options: { - sourceMap: true, - presets: ["es2015"], - plugins: ['transform-es2015-modules-systemjs', "transform-es2015-for-of"], + presets: ["es2015"] }, dist: { + options: { + sourceMap: true, + plugins: ['transform-es2015-modules-systemjs', "transform-es2015-for-of"] + }, files: [{ cwd: 'src', expand: true, @@ -57,6 +59,34 @@ module.exports = function(grunt) { dest: 'dist/' }] }, + distTestNoSystemJs: { + files: [{ + cwd: 'src', + expand: true, + src: ['**/*.js'], + dest: 'dist/test' + }] + }, + distTestsSpecsNoSystemJs: { + files: [{ + expand: true, + cwd: 'specs', + src: ['**/*.js'], + dest: 'dist/test/specs' + }] + } + }, + + mochaTest: { + test: { + options: { + reporter: 'spec' + }, + src: [ + 'dist/test/datasource-zabbix/specs/test-main.js', + 'dist/test/datasource-zabbix/specs/*_specs.js' + ] + } }, sass: { @@ -102,6 +132,7 @@ module.exports = function(grunt) { 'jshint', 'jscs', 'sass', - 'babel' + 'babel', + 'mochaTest' ]); }; diff --git a/package.json b/package.json index 560e34b..5d305bb 100644 --- a/package.json +++ b/package.json @@ -23,19 +23,28 @@ "grunt-contrib-copy": "~0.8.2", "grunt-contrib-watch": "^0.6.1", "grunt-contrib-uglify": "~0.11.0", + "grunt-mocha-test": "~0.12.7", "grunt-systemjs-builder": "^0.2.5", "load-grunt-tasks": "~3.2.0", "grunt-execute": "~0.2.2", - "grunt-contrib-clean": "~0.6.0" + "grunt-contrib-clean": "~0.6.0", + "prunk": "~1.2.1", + "jsdom": "~3.1.2", + "q": "~1.4.1", + "chai": "~3.5.0", + "sinon-chai": "~2.8.0", + "moment": "~2.14.1" }, "dependencies": { "babel-plugin-transform-es2015-modules-systemjs": "^6.5.0", - "babel-plugin-transform-es2015-for-of": "^6.5.0", + "babel-plugin-transform-es2015-for-of": "^6.6.0", "babel-preset-es2015": "^6.5.0", "grunt-contrib-jshint": "^1.0.0", "grunt-jscs": "^2.8.0", "jshint-stylish": "^2.1.0", - "lodash": "~4.0.0" + "lodash": "~4.0.0", + "mocha": "^2.4.5", + "sinon": "~1.16.1" }, "homepage": "http://grafana-zabbix.org" } diff --git a/src/datasource-zabbix/datasource.js b/src/datasource-zabbix/datasource.js index f2176c2..b860833 100644 --- a/src/datasource-zabbix/datasource.js +++ b/src/datasource-zabbix/datasource.js @@ -449,7 +449,7 @@ function formatMetric(metricObj) { * template variables, for example * /CPU $cpu_item.*time/ where $cpu_item is system,user,iowait */ -function zabbixTemplateFormat(value) { +export function zabbixTemplateFormat(value) { if (typeof value === 'string') { return utils.escapeRegex(value); } @@ -468,7 +468,7 @@ function zabbixTemplateFormat(value) { */ function replaceTemplateVars(templateSrv, target, scopedVars) { var replacedTarget = templateSrv.replace(target, scopedVars, zabbixTemplateFormat); - if (target !== replacedTarget && !utils.regexPattern.test(replacedTarget)) { + if (target !== replacedTarget && !utils.isRegex(replacedTarget)) { replacedTarget = '/^' + replacedTarget + '$/'; } return replacedTarget; diff --git a/src/datasource-zabbix/specs/datasource_specs.js b/src/datasource-zabbix/specs/datasource_specs.js new file mode 100644 index 0000000..923d99a --- /dev/null +++ b/src/datasource-zabbix/specs/datasource_specs.js @@ -0,0 +1,237 @@ +import {Datasource} from "../module"; +import {zabbixTemplateFormat} from "../datasource"; +import Q from "q"; +import sinon from 'sinon'; +import _ from 'lodash'; + +describe('ZabbixDatasource', () => { + let ctx = {}; + let defined = sinon.match.defined; + + beforeEach(() => { + ctx.instanceSettings = { + jsonData: { + username: 'zabbix', + password: 'zabbix', + trends: true, + trendsFrom: '7d' + } + }; + ctx.$q = Q; + ctx.templateSrv = {}; + ctx.alertSrv = {}; + ctx.zabbixAPIService = () => {}; + ctx.ZabbixCachingProxy = () => {}; + ctx.QueryProcessor = () => {}; + + ctx.ds = new Datasource(ctx.instanceSettings, ctx.$q, ctx.templateSrv, ctx.alertSrv, + ctx.zabbixAPIService, ctx.ZabbixCachingProxy, ctx.QueryProcessor); + }); + + describe('When querying data', () => { + beforeEach(() => { + ctx.ds.replaceTemplateVars = (str) => str; + }); + + ctx.options = { + targets: [ + { + group: {filter: ""}, + host: {filter: ""}, + application: {filter: ""}, + item: {filter: ""} + } + ], + range: {from: 'now-7d', to: 'now'} + }; + + it('should return an empty array when no targets are set', (done) => { + let options = { + targets: [], + range: {from: 'now-6h', to: 'now'} + }; + ctx.ds.query(options).then(result => { + expect(result.data).to.have.length(0); + done(); + }); + }); + + it('should use trends if it enabled and time more than trendsFrom', (done) => { + let ranges = ['now-7d', 'now-168h', 'now-1M', 'now-1y']; + + _.forEach(ranges, range => { + ctx.options.range.from = range; + ctx.ds.queryNumericData = sinon.spy(); + ctx.ds.query(ctx.options); + + // Check that useTrends options is true + expect(ctx.ds.queryNumericData) + .to.have.been.calledWith(defined, defined, defined, true); + }); + + done(); + }); + + it('shouldnt use trends if it enabled and time less than trendsFrom', (done) => { + let ranges = ['now-6d', 'now-167h', 'now-1h', 'now-30m', 'now-30s']; + + _.forEach(ranges, range => { + ctx.options.range.from = range; + ctx.ds.queryNumericData = sinon.spy(); + ctx.ds.query(ctx.options); + + // Check that useTrends options is false + expect(ctx.ds.queryNumericData) + .to.have.been.calledWith(defined, defined, defined, false); + }); + done(); + }); + + }); + + describe('When replacing template variables', () => { + + function testReplacingVariable(target, varValue, expectedResult, done) { + ctx.ds.templateSrv.replace = () => { + return zabbixTemplateFormat(varValue); + }; + + let result = ctx.ds.replaceTemplateVars(target); + expect(result).to.equal(expectedResult); + done(); + } + + /* + * Alphanumerics, spaces, dots, dashes and underscores + * are allowed in Zabbix host name. + * 'AaBbCc0123 .-_' + */ + it('should return properly escaped regex', (done) => { + let target = '$host'; + let template_var_value = 'AaBbCc0123 .-_'; + let expected_result = '/^AaBbCc0123 \\.-_$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + + /* + * Single-value variable + * $host = backend01 + * $host => /^backend01|backend01$/ + */ + it('should return proper regex for single value', (done) => { + let target = '$host'; + let template_var_value = 'backend01'; + let expected_result = '/^backend01$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + + /* + * Multi-value variable + * $host = [backend01, backend02] + * $host => /^(backend01|backend01)$/ + */ + it('should return proper regex for multi-value', (done) => { + let target = '$host'; + let template_var_value = ['backend01', 'backend02']; + let expected_result = '/^(backend01|backend02)$/'; + + testReplacingVariable(target, template_var_value, expected_result, done); + }); + }); + + describe('When invoking metricFindQuery()', () => { + beforeEach(() => { + ctx.ds.replaceTemplateVars = (str) => str; + ctx.ds.zabbixCache = { + getGroups: () => Q.when([]) + }; + ctx.ds.queryProcessor = { + getGroups: () => Q.when([]), + getHosts: () => Q.when([]), + getApps: () => Q.when([]), + getItems: () => Q.when([]) + }; + }); + + it('should return groups', (done) => { + const tests = [ + {query: '*', expect: '/.*/'}, + {query: '', expect: ''}, + {query: 'Backend', expect: 'Backend'}, + {query: 'Back*', expect: 'Back*'} + ]; + + let getGroups = sinon.spy(ctx.ds.zabbixCache, 'getGroups'); + for (const test of tests) { + ctx.ds.metricFindQuery(test.query); + expect(getGroups).to.have.been.calledWith(test.expect); + getGroups.reset(); + } + done(); + }); + + it('should return hosts', (done) => { + const tests = [ + {query: '*.*', expect: '/.*/'}, + {query: '.', expect: ''}, + {query: 'Backend.*', expect: 'Backend'}, + {query: 'Back*.', expect: 'Back*'} + ]; + + let getHosts = sinon.spy(ctx.ds.queryProcessor, 'getHosts'); + for (const test of tests) { + ctx.ds.metricFindQuery(test.query); + expect(getHosts).to.have.been.calledWith(test.expect); + getHosts.reset(); + } + done(); + }); + + it('should return applications', (done) => { + const tests = [ + {query: '*.*.*', expect: ['/.*/', '/.*/']}, + {query: '.*.', expect: ['', '/.*/']}, + {query: 'Backend.backend01.*', expect: ['Backend', 'backend01']}, + {query: 'Back*.*.', expect: ['Back*', '/.*/']} + ]; + + let getApps = sinon.spy(ctx.ds.queryProcessor, 'getApps'); + for (const test of tests) { + ctx.ds.metricFindQuery(test.query); + expect(getApps).to.have.been.calledWith(test.expect[0], test.expect[1]); + getApps.reset(); + } + done(); + }); + + it('should return items', (done) => { + const tests = [ + {query: '*.*.*.*', expect: ['/.*/', '/.*/', '']}, + {query: '.*.*.*', expect: ['', '/.*/', '']}, + {query: 'Backend.backend01.*.*', expect: ['Backend', 'backend01', '']}, + {query: 'Back*.*.cpu.*', expect: ['Back*', '/.*/', 'cpu']} + ]; + + let getItems = sinon.spy(ctx.ds.queryProcessor, 'getItems'); + for (const test of tests) { + ctx.ds.metricFindQuery(test.query); + expect(getItems) + .to.have.been.calledWith(test.expect[0], test.expect[1], test.expect[2]); + getItems.reset(); + } + done(); + }); + + it('should invoke method with proper arguments', (done) => { + let query = '*.*'; + + let getHosts = sinon.spy(ctx.ds.queryProcessor, 'getHosts'); + ctx.ds.metricFindQuery(query); + expect(getHosts).to.have.been.calledWith('/.*/'); + done(); + }); + }); + +}); diff --git a/src/datasource-zabbix/specs/modules/datemath.js b/src/datasource-zabbix/specs/modules/datemath.js new file mode 100644 index 0000000..efbf0bf --- /dev/null +++ b/src/datasource-zabbix/specs/modules/datemath.js @@ -0,0 +1,111 @@ +import _ from 'lodash'; +import moment from 'moment'; + +var units = ['y', 'M', 'w', 'd', 'h', 'm', 's']; + +export function parse(text, roundUp) { + if (!text) { return undefined; } + if (moment.isMoment(text)) { return text; } + if (_.isDate(text)) { return moment(text); } + + var time; + var mathString = ''; + var index; + var parseString; + + if (text.substring(0, 3) === 'now') { + time = moment(); + mathString = text.substring('now'.length); + } else { + index = text.indexOf('||'); + if (index === -1) { + parseString = text; + mathString = ''; // nothing else + } else { + parseString = text.substring(0, index); + mathString = text.substring(index + 2); + } + // We're going to just require ISO8601 timestamps, k? + time = moment(parseString, moment.ISO_8601); + } + + if (!mathString.length) { + return time; + } + + return parseDateMath(mathString, time, roundUp); +} + +export function isValid(text) { + var date = parse(text); + if (!date) { + return false; + } + + if (moment.isMoment(date)) { + return date.isValid(); + } + + return false; +} + +export function parseDateMath(mathString, time, roundUp) { + var dateTime = time; + var i = 0; + var len = mathString.length; + + while (i < len) { + var c = mathString.charAt(i++); + var type; + var num; + var unit; + + if (c === '/') { + type = 0; + } else if (c === '+') { + type = 1; + } else if (c === '-') { + type = 2; + } else { + return undefined; + } + + if (isNaN(mathString.charAt(i))) { + num = 1; + } else if (mathString.length === 2) { + num = mathString.charAt(i); + } else { + var numFrom = i; + while (!isNaN(mathString.charAt(i))) { + i++; + if (i > 10) { return undefined; } + } + num = parseInt(mathString.substring(numFrom, i), 10); + } + + if (type === 0) { + // rounding is only allowed on whole, single, units (eg M or 1M, not 0.5M or 2M) + if (num !== 1) { + return undefined; + } + } + unit = mathString.charAt(i++); + + if (!_.includes(units, unit)) { + return undefined; + } else { + if (type === 0) { + if (roundUp) { + dateTime.endOf(unit); + } else { + dateTime.startOf(unit); + } + } else if (type === 1) { + dateTime.add(num, unit); + } else if (type === 2) { + dateTime.subtract(num, unit); + } + } + } + return dateTime; +} diff --git a/src/datasource-zabbix/specs/test-main.js b/src/datasource-zabbix/specs/test-main.js new file mode 100644 index 0000000..cdaec55 --- /dev/null +++ b/src/datasource-zabbix/specs/test-main.js @@ -0,0 +1,49 @@ +// JSHint options +/* globals global: false */ + +import prunk from 'prunk'; +import {jsdom} from 'jsdom'; +import chai from 'chai'; +// import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as dateMath from './modules/datemath'; + +// Mock angular module +var angularMocks = { + module: function() { + return { + directive: function() {}, + service: function() {}, + factory: function() {} + }; + } +}; + +var datemathMock = { + parse: dateMath.parse, + parseDateMath: dateMath.parseDateMath, + isValid: dateMath.isValid +}; + +// Mock Grafana modules that are not available outside of the core project +// Required for loading module.js +prunk.mock('./css/query-editor.css!', 'no css, dude.'); +prunk.mock('app/plugins/sdk', { + QueryCtrl: null +}); +prunk.mock('app/core/utils/datemath', datemathMock); +prunk.mock('angular', angularMocks); +prunk.mock('jquery', 'module not found'); + +// Setup jsdom +// Required for loading angularjs +global.document = jsdom('
'); +global.window = global.document.parentWindow; +global.navigator = window.navigator = {}; +global.Node = window.Node; + +// Setup Chai +chai.should(); +chai.use(sinonChai); +global.assert = chai.assert; +global.expect = chai.expect;