diff --git a/.babelrc b/.babelrc index cab5d10d..60e33aa0 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,8 @@ { + "env": { + "test": { + "plugins": ["babel-plugin-rewire"] + } + }, "stage": 2 } diff --git a/package.json b/package.json index 5fc56634..c9eec2ae 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "babel-core": "^5.8.29", "babel-eslint": "^4.1.3", "babel-loader": "^5.3.2", + "babel-plugin-rewire": "^0.1.22", "conventional-changelog": "^0.5.1", "cssnano": "^3.4.0", "doctoc": "^0.15.0", @@ -50,6 +51,7 @@ "postcss-cli": "^2.3.2", "pretty-bytes": "^2.0.1", "semver": "^5.1.0", + "sinon": "^1.17.2", "uglify-js": "^2.6.1", "webpack": "^1.12.2", "webpack-dev-server": "^1.12.1" diff --git a/scripts/bump-package-version.js b/scripts/bump-package-version.js index b257b247..989bf5cd 100644 --- a/scripts/bump-package-version.js +++ b/scripts/bump-package-version.js @@ -1,3 +1,4 @@ +/* eslint no-console:0 max-len:0 */ import fs from 'fs'; import path from 'path'; diff --git a/scripts/docs/gh-pages.js b/scripts/docs/gh-pages.js index 47dbdab3..83ae8fae 100644 --- a/scripts/docs/gh-pages.js +++ b/scripts/docs/gh-pages.js @@ -1,3 +1,4 @@ +/* eslint no-console:0 */ import ghpages from 'gh-pages'; import {join} from 'path'; diff --git a/src/lib/DocSearch.js b/src/lib/DocSearch.js index 3ae47bd2..ab493d07 100644 --- a/src/lib/DocSearch.js +++ b/src/lib/DocSearch.js @@ -36,12 +36,18 @@ class DocSearch { hint: false } }) { - this.checkArguments({apiKey, indexName, inputSelector, algoliaOptions, autocompleteOptions}); + DocSearch.checkArguments({apiKey, indexName, inputSelector, algoliaOptions, autocompleteOptions}); + + this.apiKey = apiKey; + this.indexName = indexName; + this.input = DocSearch.getInputFromSelector(inputSelector); + this.algoliaOptions = algoliaOptions; + this.autocompleteOptions = autocompleteOptions; this.client = algoliasearch('BH4D9OD16A', this.apiKey); this.client.addAlgoliaAgent('docsearch.js ' + version); this.autocomplete = autocomplete(this.input, autocompleteOptions, [{ - source: this.getSource(), + source: this.getAutocompleteSource(), templates: { suggestion: this.getSuggestionTemplate(), footer: '' @@ -50,26 +56,42 @@ class DocSearch { this.autocomplete.on('autocomplete:selected', this.handleSelected); } - checkArguments(args) { + /** + * Checks that the passed arguments are valid. Will throw errors otherwise + * @function checkArguments + * @param {object} args Arguments as an option object + * @returns {void} + */ + static checkArguments(args) { if (!args.apiKey || !args.indexName) { throw new Error(usage); } - const input = $(args.inputSelector).filter('input'); - if (input.length === 0) { + if (!DocSearch.getInputFromSelector(args.inputSelector)) { throw new Error(`Error: No input element in the page matches ${args.inputSelector}`); } - - this.apiKey = args.apiKey; - this.indexName = args.indexName; - this.input = input; - this.algoliaOptions = args.algoliaOptions; - this.autocompleteOptions = args.autocompleteOptions; } - // Returns a `source` method to be used by `autocomplete`. This will query the - // Algolia index. - getSource() { + /** + * Returns the matching input from a CSS selector, null if none matches + * @function getInputFromSelector + * @param {string} selector CSS selector that matches the search + * input of the page + * @returns {void} + */ + static getInputFromSelector(selector) { + let input = $(selector).filter('input'); + return input.length ? $(input[0]) : null; + } + + /** + * Returns the `source` method to be passed to autocomplete.js. It will query + * the Algolia index and call the callbacks with the formatted hits. + * @function getAutocompleteSource + * @returns {function} Method to be passed as the `source` option of + * autocomplete + */ + getAutocompleteSource() { return (query, callback) => { this.client.search([{ indexName: this.indexName, diff --git a/src/lib/templates.js b/src/lib/templates.js index 138432ca..660fce69 100644 --- a/src/lib/templates.js +++ b/src/lib/templates.js @@ -2,7 +2,10 @@ let prefix = 'ads-suggestion'; let templates = { suggestion: ` -
+
{{{category}}}
{{{subcategory}}}
diff --git a/test/DocSearch-test.js b/test/DocSearch-test.js index fc0c96f2..3aeafb1a 100644 --- a/test/DocSearch-test.js +++ b/test/DocSearch-test.js @@ -1,107 +1,257 @@ /* eslint-env mocha */ +/* eslint no-new:0 */ import jsdom from 'mocha-jsdom'; import expect from 'expect'; -import fixtures from 'node-fixtures'; +import sinon from 'sinon'; +// import fixtures from 'node-fixtures'; describe('DocSearch', () => { - let docSearch; + let DocSearch; let $; jsdom({useEach: true}); - beforeEach(() => { // We need a DOM to be ready before importing Zepto - docSearch = require('../index.js'); + // We need to load DocSearch from here as it depends on Zepto, which itself + // needs jsdom to be called before being loaded. + DocSearch = require('../src/lib/DocSearch.js'); $ = require('npm-zepto'); // Note: If you edit this HTML while doing TDD with `npm run test:watch`, // you will have to restart `npm run test:watch` for the new HTML to be // updated - document.body.innerHTML = '
'; + document.body.innerHTML = ` +
+ + span span +
+ `; + }); + + describe('constructor', () => { + let AlgoliaSearch; + let algoliasearch; + let AutoComplete; + let autocomplete; + let checkArguments; + let getInputFromSelector; + let checkArgumentsInitial; + let getInputFromSelectorInitial; + let defaultOptions; + + beforeEach(() => { + algoliasearch = { + algolia: 'client', + addAlgoliaAgent: sinon.spy() + }; + AlgoliaSearch = sinon.stub().returns(algoliasearch); + autocomplete = { + on: sinon.spy() + }; + AutoComplete = sinon.stub().returns(autocomplete); + + checkArgumentsInitial = DocSearch.checkArguments; + checkArguments = sinon.spy(); + getInputFromSelectorInitial = DocSearch.getInputFromSelector; + getInputFromSelector = sinon.stub(); + defaultOptions = { + indexName: 'indexName', + apiKey: 'apiKey', + inputSelector: '#input' + }; + + DocSearch.checkArguments = checkArguments; + DocSearch.getInputFromSelector = getInputFromSelector; + + DocSearch.__Rewire__('algoliasearch', AlgoliaSearch); + DocSearch.__Rewire__('autocomplete', AutoComplete); + }); + + afterEach(() => { + // Cleanup the stubs on static methods + DocSearch.checkArguments = checkArgumentsInitial; + DocSearch.getInputFromSelector = getInputFromSelectorInitial; + }); + + it('should call checkArguments', () => { + // Given + let options = defaultOptions; + + // When + new DocSearch(options); + + // Then + expect(checkArguments.calledOnce).toBe(true); + }); + it('should pass main options as instance properties', () => { + // Given + let options = defaultOptions; + + // When + let actual = new DocSearch(options); + + // Then + expect(actual.indexName).toEqual('indexName'); + expect(actual.apiKey).toEqual('apiKey'); + }); + it('should pass the input element as an instance property', () => { + // Given + let options = defaultOptions; + getInputFromSelector.returns($('foo')); + + // When + let actual = new DocSearch(options); + + // Then + let $input = actual.input; + expect($input.text()).toEqual('foo'); + expect($input[0].tagName).toEqual('SPAN'); + }); + it('should pass secondary options as instance properties', () => { + // Given + let options = { + ...defaultOptions, + algoliaOptions: 'algoliaOptions', + autocompleteOptions: 'autocompleteOptions' + }; + + // When + let actual = new DocSearch(options); + + // Then + expect(actual.algoliaOptions).toEqual('algoliaOptions'); + expect(actual.autocompleteOptions).toEqual('autocompleteOptions'); + }); + it('should instanciate algoliasearch with the correct values', () => { + // Given + let options = defaultOptions; + + // When + new DocSearch(options); + + // Then + expect(AlgoliaSearch.calledOnce).toBe(true); + expect(AlgoliaSearch.calledWith('BH4D9OD16A', 'apiKey')).toBe(true); + }); + it('should set a custom User-Agent to algoliasearch', () => { + // Given + let options = defaultOptions; + + // When + new DocSearch(options); + + // Then + expect(algoliasearch.addAlgoliaAgent.calledOnce).toBe(true); + }); + it('should instanciate autocomplete.js', () => { + // Given + let options = { + ...defaultOptions, + autocompleteOptions: 'bar' + }; + let $input = $(''); + getInputFromSelector.returns($input); + + // When + new DocSearch(options); + + // Then + expect(AutoComplete.calledOnce).toBe(true); + expect(AutoComplete.calledWith($input, 'bar')).toBe(true); + }); + it('should listen to the selected event of autocomplete', () => { + // Given + let options = defaultOptions; + + // When + new DocSearch(options); + + // Then + expect(autocomplete.on.calledOnce).toBe(true); + expect(autocomplete.on.calledWith('autocomplete:selected')).toBe(true); + }); }); describe('checkArguments', () => { + let checkArguments; + beforeEach(() => { + checkArguments = DocSearch.checkArguments; + }); + it('should throw an error if no apiKey defined', () => { // Given - let input = { + let options = { indexName: 'indexName' }; // When expect(() => { - docSearch(input); + checkArguments(options); }).toThrow(/^Usage:/); }); it('should throw an error if no indexName defined', () => { // Given - let input = { + let options = { apiKey: 'apiKey' }; // When expect(() => { - docSearch(input); + checkArguments(options); }).toThrow(/^Usage:/); }); - it('should throw an error if no input element matches the selector in the page', () => { + it('should throw an error if no selector matches', () => { // Given - let input = { + let options = { apiKey: 'apiKey', - indexName: 'indexName', - inputSelector: '#unknown-input' + indexName: 'indexName' }; + let getInputFromSelector = sinon.stub().returns(false); + DocSearch.prototype.getInputFromSelector = getInputFromSelector; // When expect(() => { - docSearch(input); + checkArguments(options); }).toThrow(/^Error:/); }); - it('should pass apiKey and indexName as properties of the instance', () => { - // Given - let input = { - apiKey: 'apiKey', - indexName: 'indexName', - inputSelector: '#input' - }; + }); - // When - let actual = docSearch(input); - - // Then - expect(actual.apiKey).toEqual('apiKey'); - expect(actual.indexName).toEqual('indexName'); + describe('getInputFromSelector', () => { + let getInputFromSelector; + beforeEach(() => { + getInputFromSelector = DocSearch.getInputFromSelector; }); - it('should pass the matching input as property of the input', () => { + + it('should return null if no element matches the selector', () => { // Given - let input = { - apiKey: 'apiKey', - indexName: 'indexName', - inputSelector: '#input' - }; + let selector = '.i-do-not-exist > at #all'; // When - let actual = docSearch(input); + let actual = getInputFromSelector(selector); // Then - expect($(actual.input).attr('name')).toEqual('input-name'); + expect(actual).toEqual(null); }); - it('should pass options as properties of the input', () => { + it('should return null if the matched element is not an input', () => { // Given - let input = { - apiKey: 'apiKey', - indexName: 'indexName', - inputSelector: '#input', - algoliaOptions: {name: 'foo'}, - autocompleteOptions: {name: 'bar'} - }; + let selector = '.i-am-a-span'; // When - let actual = docSearch(input); + let actual = getInputFromSelector(selector); // Then - expect(actual.algoliaOptions.name).toEqual('foo'); - expect(actual.autocompleteOptions.name).toEqual('bar'); + expect(actual).toEqual(null); + }); + it('should return a Zepto wrapped element if it matches', () => { + // Given + let selector = '#input'; + + // When + let actual = getInputFromSelector(selector); + + // Then + expect($.zepto.isZ(actual)).toBe(true); }); }); }); diff --git a/test/helpers.js b/test/helpers.js index 191d31cd..410cb6b7 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,3 +1,4 @@ +/* eslint-env mocha */ global.ddescribe = describe.only; global.xdescribe = describe.skip; global.iit = it.only; diff --git a/test/utils-test.js b/test/utils-test.js index ddd95b42..d8e3714c 100644 --- a/test/utils-test.js +++ b/test/utils-test.js @@ -10,7 +10,7 @@ describe('utils', () => { before(() => { // We need to load utils from here as it depends on Zepto, which itself // needs jsdom to be called before being loaded. - utils = require('../src/lib/utils'); + utils = require('../src/lib/utils.js'); }); describe('mergeKeyWithParent', () => { @@ -175,16 +175,16 @@ describe('utils', () => { it('should flatten all values', () => { // Given let input = { - 'devs': [ + devs: [ {name: 'Tim', category: 'dev'}, {name: 'Vincent', category: 'dev'}, {name: 'AlexS', category: 'dev'} - ], - 'sales': [ + ], + sales: [ {name: 'Ben', category: 'sales'}, {name: 'Jeremy', category: 'sales'}, {name: 'AlexK', category: 'sales'} - ] + ] }; // When diff --git a/webpack.dev.config.babel.js b/webpack.dev.config.babel.js index f9ed4e1d..3fe28cf0 100644 --- a/webpack.dev.config.babel.js +++ b/webpack.dev.config.babel.js @@ -26,5 +26,5 @@ export default { // same issue, for loaders like babel resolveLoader: { fallback: [join(__dirname, '..', 'node_modules')] - }, + } };