├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── blueprints.config.js ├── flags.js ├── index.es6.js ├── package.json └── test └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-class-properties", "transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "root": true, 5 | 6 | "env": { 7 | // I write for browser 8 | "browser": true, 9 | // in CommonJS 10 | "node": true, 11 | "mocha": true 12 | }, 13 | 14 | "extends": "eslint:recommended", 15 | 16 | // plugins let use use custom rules below 17 | "plugins": [ 18 | "babel", 19 | "react" 20 | ], 21 | 22 | "ecmaFeatures": { 23 | "jsx": true, 24 | "es6": true, 25 | }, 26 | 27 | "rules": { 28 | // 0 = off 29 | // 1 = warn 30 | // 2 = error 31 | 32 | // React specifc rules 33 | "react/jsx-boolean-value": 0, 34 | "react/jsx-closing-bracket-location": 1, 35 | "react/jsx-curly-spacing": [2, "always"], 36 | "react/jsx-indent-props": [1, 2], 37 | "react/jsx-no-undef": 1, 38 | "react/jsx-uses-react": 1, 39 | "react/jsx-uses-vars": 1, 40 | "react/wrap-multilines": 1, 41 | "react/react-in-jsx-scope": 1, 42 | "react/prefer-es6-class": 1, 43 | // no binding functions in render for perf 44 | "react/jsx-no-bind": 1, 45 | 46 | // handle async/await functions correctly 47 | // handle object spread 48 | "babel/object-shorthand": 1, 49 | 50 | // handle potential errors 51 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 52 | "comma-dangle": [2, "always-multiline"], 53 | "consistent-return": 0, 54 | "indent": [2, 2, {"SwitchCase": 1}], 55 | "max-len": [1, 100, 2, {"ignoreComments": true}], 56 | "no-cond-assign": [2, "always"], 57 | "no-console": 0, 58 | "no-constant-condition": 2, 59 | "no-control-regex": 2, 60 | "no-debugger": 2, 61 | "no-dupe-args": 2, 62 | "no-dupe-keys": 2, 63 | "no-duplicate-case": 2, 64 | "no-empty-character-class": 2, 65 | "no-empty": 2, 66 | "no-ex-assign": 2, 67 | "no-extra-boolean-cast": 2, 68 | "no-extra-semi": 2, 69 | "no-func-assign": 2, 70 | "no-invalid-regexp": 2, 71 | "no-irregular-whitespace": 2, 72 | "no-negated-in-lhs": 2, 73 | "no-obj-calls": 2, 74 | "no-regex-spaces": 2, 75 | "no-sparse-arrays": 2, 76 | "no-unexpected-multiline": 2, 77 | "no-unreachable": 2, 78 | "no-underscore-dangle": 0, 79 | "no-unused-vars": 1, 80 | "quotes": [2, "single"], 81 | "semi": [2, "always"], 82 | "space-after-keywords": [2, "always"], 83 | "space-before-blocks": [2, "always"], 84 | "space-before-function-parentheses": [0, "never"], 85 | "space-in-brackets": [0, "never"], 86 | "space-in-parens": [2, "never"], 87 | "space-return-throw-case": 1, 88 | "space-unary-ops": [1, { "words": true, "nonwords": false }], 89 | "strict": [2, "never"], 90 | "use-isnan": 2, 91 | 92 | // style 93 | "curly": 2, 94 | "dot-location": [2, 'property'], 95 | "dot-notation": 2, 96 | "eqeqeq": 2, 97 | "no-alert": 2, 98 | "no-else-return": 2, 99 | "no-eval": 2, 100 | "no-extra-bind": 2, 101 | "no-fallthrough": 2, 102 | "no-floating-decimal": 2, 103 | "no-implied-eval": 2, 104 | "no-iterator": 2, 105 | "no-labels": 2, 106 | "no-lone-blocks": 2, 107 | "no-loop-func": 2, 108 | "no-multi-spaces": 2, 109 | "no-multi-str": 2, 110 | "no-native-reassign": 2, 111 | "no-new-func": 2, 112 | "no-new-wrappers": 2, 113 | "no-new": 2, 114 | "no-octal-escape": 2, 115 | "no-octal": 2, 116 | "no-proto": 2, 117 | "no-redeclare": 2, 118 | "no-return-assign": 2, 119 | "no-self-compare": 2, 120 | "no-sequences": 2, 121 | "no-throw-literal": 1, 122 | "no-unused-expressions": 2, 123 | "no-useless-call": 2, 124 | "no-useless-concat": 2, 125 | "no-void": 2, 126 | "no-warning-comments": [1, { "terms": ["todo", "fixme", "xxx"], "location": "start" }], 127 | "no-with": 2, 128 | "yoda": 2, 129 | "no-delete-var": 2, 130 | "no-shadow-restricted-names": 2, 131 | "no-shadow": [0, { "hoist": "never" }], 132 | "no-undef-init": 2, 133 | "no-undef": 2, 134 | "no-unused-vars": 2, 135 | "callback-return": 2, 136 | 137 | // es6-specific 138 | "prefer-template": 1, 139 | "no-var": 1, 140 | "constructor-super": 2, 141 | "prefer-const": 1, 142 | "prefer-spread": 2, 143 | "no-dupe-class-members": 2, 144 | // commented out; there's a bug where `async` functions throw an error here. 145 | //"generator-star-spacing": [0, { "before": true, "after": false }], 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslintrc 3 | .travis.yml 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4" 5 | - "5" 6 | 7 | script: 8 | - npm run lint 9 | - npm run test 10 | 11 | branches: 12 | except: 13 | - staging 14 | 15 | env: 16 | - CXX=g++-4.8 17 | 18 | addons: 19 | apt: 20 | sources: 21 | - ubuntu-toolchain-r-test 22 | packages: 23 | - g++-4.8 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to flags 2 | ==================== 3 | 4 | So you want to contribute to flags? Fantastic! Here's a brief overview on 5 | how best to do so. 6 | 7 | ## What to change 8 | 9 | Here's some examples of things you might want to make a pull request for: 10 | 11 | * New features 12 | * Bugfixes 13 | * Inefficient blocks of code 14 | 15 | If you have a more deeply-rooted problem with how the program is built or some 16 | of the stylistic decisions made in the code, it's best to 17 | [create an issue](https://github.com/reddit/flags/issues) before putting 18 | the effort into a pull request. The same goes for new features - it is 19 | best to check the project's direction, existing pull requests, and currently open 20 | and closed issues first. 21 | 22 | ## Style 23 | 24 | * Two spaces, not tabs 25 | * Semicolons are not optional 26 | * All pages should render on the server and the client. The site should be 27 | usable without javascript. 28 | * Review our [style guide](https://github.com/reddit/tree/master/javascript) for 29 | more information. 30 | * Use the .eslint file here to make things go smoothly! 31 | 32 | Look at existing code to get a good feel for the patterns we use. Please run 33 | tests before submitting any pull requests. Instructions for running tests can 34 | be found in the README. 35 | 36 | ## Using Git appropriately 37 | 38 | 1. [Fork the repository](https://github.com/reddit/flags/fork_select) to 39 | your Github account. 40 | 2. Create a *topical branch* - a branch whose name is succint but explains what 41 | you're doing, such as "change-orangered-to-periwinkle" 42 | 3. Make your changes, committing at logical breaks. 43 | 4. Push your branch to your personal account 44 | 5. [Create a pull request](https://help.github.com/articles/using-pull-requests) 45 | 6. Watch for comments or acceptance 46 | 47 | Please make separate branches for unrelated changes! 48 | 49 | ## Licensing 50 | 51 | flags is MIT licensed. See details in the LICENSE file. This is a very permissive 52 | scheme, GPL-compatible but without many of the restrictions of GPL. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 reddit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | flags 2 | ==== 3 | 4 | A simple feature flagging library. 5 | 6 | 100 lines of code, no dependencies. Written in ES6+. 7 | 8 | [![Build Status](https://travis-ci.org/reddit/flags.svg?branch=master)](https://travis-ci.org/reddit/flags) 9 | 10 | __flags__ lets you quickly switch features on or off based on a context. This is 11 | useful to test features for specific users (such as flagging on new functionality 12 | in a web application by reading the response context), dark-launching code, 13 | and a/b testing. 14 | 15 | ```javascript 16 | // Import it! 17 | import Flags from '@r/flags'; 18 | 19 | // Set up your experiment config! 20 | const config = { 21 | loggedoutSearch: { loggedin: false }, 22 | oldDesign: false, 23 | newListingStyle: { users: ['ajacksified'] }, 24 | }; 25 | 26 | const feature = new Flags(config); 27 | 28 | // Add rules! 29 | feature.addRule('loggedin', function(val) { return this.loggedin === val; }); 30 | feature.addRule('users', function(names) { return names.includes(this.username); }); 31 | 32 | // Get whatever blob of data you'll use to determine your experiment truthiness 33 | const userData = { 34 | loggedin: true, 35 | username: 'ajacksified', 36 | }; 37 | 38 | // For ease of use, build a context-bound flags instance. (Alternatively, you 39 | // could call `feature.on(rule, context)` instead.) 40 | const featureContext = feature.withContext(userData); 41 | 42 | // Build your UI with a Flags context bound to userdata! 43 | 44 | // false (loggedin is true, but the config wants false) 45 | if (featureContext.enabled('loggedoutSearch')) { 46 | searchControl = ; 47 | console.log('show the logged out search'); 48 | } 49 | 50 | // false (the config says it's always false) 51 | if (featureContext.enabled('oldDesign')) { 52 | layout = ; 53 | } 54 | 55 | // true (the username is in the users array) 56 | if (featureContext.enabled('newListingStyle')) { 57 | listing = ; 58 | } 59 | 60 | // Get the list of enabled featues: 61 | const enabled = featureContext.allEnabled(); 62 | // => ['newListingStyle']; 63 | 64 | 65 | // Or disabled: 66 | const disabled = featureContext.allDisabled(); 67 | // => ['loggedoutSearch', 'oldDesign']; 68 | ``` 69 | 70 | Rules and Configuration 71 | ----------------------- 72 | 73 | __flags__ configuration and rules are very simple: 74 | 75 | * Define a name for your features; such as `'loggedoutSearch'` or `'oldDesign'` as above. These 76 | will be the basis if your config and your feature flags for later on. 77 | * Define the rules upon which your feature will be on or off. Above, we implement 78 | a boolean check (shoes) and an array check (username). 79 | * Check the flag - either by sending it in (`feature.enabled('feature', { data } )`) or 80 | by hanging on to a context-bound instance ( 81 | `featureContext = feature.withContext({ data }); featureContext.enabled('feature')`) 82 | 83 | Of note: if a rule is defined in configuration but it is not implemented, it is 84 | assumed to be false. 85 | 86 | Three special "pseudo-rules" implement boolean operators: `and`, `or`, and 87 | `not`, so that rules may stay simple and general and be combined in useful 88 | ways. For example: 89 | 90 | ```javascript 91 | const config = { 92 | friend: { 93 | and: [ 94 | { or: [ { type: 'cyborg' }, { type: 'human' } ] }, 95 | { not: { role: 'supervillain' } } 96 | ] 97 | } 98 | }; 99 | ``` 100 | 101 | You can also parse large objects to handle things such as environment variables. 102 | To do so, you should have configuration in the format `feature_name`. `feature_` 103 | will be stripped from the key and lowercased: 104 | 105 | ```javascript 106 | import Flags from flags; 107 | 108 | const config = Flags.parseConfig({ 109 | something: 'wut', 110 | feature_flippers: { names: ['bob', 'jane'] } 111 | }); 112 | 113 | // => { flippers: { names: ['bob', 'jane'] } }; 114 | ``` 115 | 116 | You can supply your own function, too, and customize both key and value. In 117 | this example, we'll expect things to start with `f_` instead of `feature_`. 118 | 119 | ```javascript 120 | import Flags from flags; 121 | 122 | const config = Flags.parseConfig({ 123 | something: 'wut', 124 | f_flippers: { names: ['bob', 'jane'] } 125 | }, function(key, val) { 126 | if (key.indexOf('f_') === -1) { return; } 127 | return { key: key.slice(2), val: val }; 128 | }); 129 | 130 | // => { flippers: { names: ['bob', 'jane'] } }; 131 | ``` 132 | 133 | You can also retrieve a list of all currently enabled or disabled rules for a 134 | given context by calling `feature.allEnabled` or `feature.allDisabled`. These 135 | can be called with a context passed in, or else will use a bound context. 136 | 137 | Sometimes, for testing, it's nice to clone an instance that doesn't use the 138 | original rules and configuration by reference. You can call 139 | `flagsinstance.clone()` to return a new flags instance with shallow copies of 140 | the config, rules, and context. 141 | 142 | Development 143 | ----------- 144 | 145 | __flags__ is an ES6 library. Take a look at the `.babelrc` file to see what 146 | presets we're using. To import it in ES5, use 147 | `var Flags = require ('@r/flags').default;`. 148 | 149 | To get started: 150 | 151 | * With node installed, 152 | * Fork __flags__ 153 | * Clone your fork to your machine 154 | * Run `npm install` to install dev dependencies (there are no regular 155 | dependencies; dependencies are for testing and running.) 156 | * Write features, and run `npm test` and `npm run lint`. Feature changes should 157 | have tests! For ease of use, install `eslint` for your favorite editor. 158 | * Check out `CONTRIBUTING.md` for more detailed info. 159 | 160 | flags is MIT licensed. copyright 2016 reddit. See LICENSE for more info. 161 | -------------------------------------------------------------------------------- /blueprints.config.js: -------------------------------------------------------------------------------- 1 | module.exports = [{ 2 | name: 'apiClient', 3 | webpack: { 4 | entry: { 5 | flags: './index.es6.js', 6 | }, 7 | output: { 8 | library: '[name].js', 9 | libraryTarget: 'umd', 10 | }, 11 | resolve: { 12 | generator: 'npm-and-modules', 13 | extensions: ['', '.js', '.jsx', '.es6.js', '.json'], 14 | }, 15 | loaders: [ 16 | 'esnextreact', 17 | 'json', 18 | ], 19 | plugins: [ 20 | 'production-loaders', 21 | 'set-node-env', 22 | 'abort-if-errors', 23 | 'minify-and-treeshake', 24 | ], 25 | externals: 'node-modules', 26 | }, 27 | }]; 28 | -------------------------------------------------------------------------------- /flags.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports["flags.js"]=t():e["flags.js"]=t()}(this,function(){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=0)}([function(e,t){"use strict";function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var r=Object.assign||function(e){for(var t=1;t { 12 | const parsed = parsefn(key, config[key]); 13 | 14 | if (parsed) { 15 | o[parsed.key] = parsed.val; 16 | } 17 | 18 | return o; 19 | }, {}); 20 | } 21 | 22 | static parse (key, val) { 23 | const k = key.toLowerCase(); 24 | if (k.indexOf(DEFAULT_FEATURE_PARSE_PREFIX) === -1) { return; } 25 | 26 | return { key: k.slice(DEFAULT_FEATURE_PARSE_PREFIX.length), val }; 27 | } 28 | 29 | // Build a new instance. In most cases, you only pass in config that sets 30 | // experiments up; sometimes, you may prefer to also pass rules in rather 31 | // than calling addRule. Passing in ctx is really only to be used internally 32 | // by withContext. 33 | constructor (featureConfig, rules={}, ctx={}) { 34 | this.config = featureConfig; 35 | this.rules = rules; 36 | this.ctx = ctx; 37 | } 38 | 39 | // Add a new rule 40 | addRule (name, fn) { 41 | this.rules[name] = fn; 42 | } 43 | 44 | testRule (config, rule, ctx) { 45 | // If there's no rule, check for boolean operator pseudo-rules. 46 | if (!this.rules[rule]) { 47 | return this.tryBooleanOps(config, rule, ctx); 48 | } 49 | 50 | // If there is a rule, return if it failed. 51 | return this.rules[rule].call(ctx, config[rule]); 52 | }; 53 | 54 | // Check for boolean operator pseudo-rules. 55 | tryBooleanOps (config, rule, ctx) { 56 | if (rule === 'or') { 57 | return config[rule].some(subConfig => 58 | Object.keys(subConfig).some(subRule => this.testRule(subConfig, subRule, ctx)) 59 | ) 60 | } 61 | 62 | if (rule === 'and') { 63 | return config[rule].every(subConfig => 64 | Object.keys(subConfig).some(subRule => this.testRule(subConfig, subRule, ctx)) 65 | ) 66 | } 67 | 68 | if (rule === 'not') { 69 | const subConfig = config[rule]; 70 | return !(Object.keys(subConfig).some(subRule => this.testRule(subConfig, subRule, ctx))); 71 | } 72 | 73 | // Otherwise, return false. 74 | return false; 75 | } 76 | 77 | // Check if your flags are on a thing 78 | enabled (name, ctx=this.ctx) { 79 | const config = this.config[name]; 80 | 81 | // If there's no config for the feature we're checking for, assume false. 82 | if (!config) { return false; } 83 | 84 | // If the flag is a boolean, just return it. 85 | if (config === true || config === false) { 86 | return config; 87 | } 88 | 89 | // Otherwise, get the list of rules from the config. 90 | const rules = Object.keys(config); 91 | 92 | // Check if any of the rules fail. Use `find`, which exits as immiediately 93 | // as possible. 94 | const pass = rules.some((r) => this.testRule(config, r, ctx)); 95 | 96 | // Return whether any of the rules failed 97 | return pass; 98 | } 99 | 100 | // Loop through all configured features and return a string array of what 101 | // features _would_ return true for a given context (or are disabled if 102 | // enabled = false.) 103 | allEnabled (ctx=this.ctx, enabled=true) { 104 | return Object.keys(this.config).filter((configName) => { 105 | return this.enabled(configName, ctx) === enabled; 106 | }); 107 | } 108 | 109 | // Loop through all configured features and return a string array of what 110 | // features _would not_ return true for a given context. 111 | allDisabled (ctx=this.ctx) { 112 | return this.allEnabled(ctx, false); 113 | } 114 | 115 | // Create a new, context-bound flags instance for easy calling later on. 116 | withContext (ctx) { 117 | return new Flags(this.config, this.rules, ctx); 118 | } 119 | 120 | clone (config=this.config, rules=this.rules, ctx=this.ctx) { 121 | return new Flags({ ...config}, { ...rules }, { ...ctx }); 122 | } 123 | } 124 | 125 | export default Flags; 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@r/flags", 3 | "version": "1.5.0", 4 | "description": "config goes in, feature-enabled goes out", 5 | "main": "flags.js", 6 | "scripts": { 7 | "watch": "blueprints -w", 8 | "build": "blueprints", 9 | "test": "mocha --compilers js:babel-register", 10 | "lint": "eslint src" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/reddit/flags.git" 15 | }, 16 | "keywords": [ 17 | "feature", 18 | "flag", 19 | "toggle", 20 | "switch", 21 | "ab", 22 | "a/b", 23 | "test" 24 | ], 25 | "author": "Jack Lawson ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/reddit/flags/issues" 29 | }, 30 | "homepage": "https://github.com/reddit/feet#readme", 31 | "devDependencies": { 32 | "@r/build": "^0.6.0", 33 | "babel-core": "^6.9.0", 34 | "babel-eslint": "^6.0.4", 35 | "babel-plugin-syntax-trailing-function-commas": "^6.8.0", 36 | "babel-plugin-transform-async-to-generator": "^6.8.0", 37 | "babel-plugin-transform-class-properties": "^6.8.0", 38 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 39 | "babel-polyfill": "6.8.0", 40 | "babel-preset-es2015": "^6.5.0", 41 | "babel-preset-react": "^6.5.0", 42 | "babel-register": "6.5.1", 43 | "chai": "^3.5.0", 44 | "eslint": "^1.10.3", 45 | "eslint-plugin-babel": "^3.1.0", 46 | "mocha": "^2.4.5", 47 | "sinon": "^1.17.3", 48 | "sinon-chai": "^2.8.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions:0 */ 2 | import chai from 'chai'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | 6 | import Feet from '../index.es6.js'; 7 | 8 | const expect = chai.expect; 9 | chai.use(sinonChai); 10 | 11 | describe('Feet', () => { 12 | describe('constructor', () => { 13 | it('exists', () => { 14 | expect(Feet).to.not.be.undefined; 15 | }); 16 | 17 | it('loads with passed-in config', () => { 18 | const config = { test: false }; 19 | const feet = new Feet(config); 20 | 21 | expect(feet.config).to.equal(config); 22 | }); 23 | 24 | it('loads with passed-in config and rules', () => { 25 | const config = { test: false }; 26 | const rules = { rule () { }}; 27 | 28 | const feet = new Feet(config, rules); 29 | 30 | expect(feet.rules).to.equal(rules); 31 | }); 32 | 33 | it('loads with passed-in config and rules and conext', () => { 34 | const config = { test: { rule: true } }; 35 | const rules = { rule: sinon.spy() }; 36 | const ctx = { name: 'hamster' }; 37 | 38 | const feet = new Feet(config, rules, ctx); 39 | 40 | expect(feet.ctx).to.equal(ctx); 41 | feet.enabled('test'); 42 | expect(rules.rule.thisValues[0]).to.equal(ctx); 43 | }); 44 | }); 45 | 46 | describe('adding rules', () => { 47 | it('adds a rule', function() { 48 | const config = { test: false }; 49 | const feet = new Feet(config); 50 | const rule = sinon.spy(); 51 | feet.addRule('shoes', rule); 52 | 53 | expect(feet.rules.shoes).to.equal(rule); 54 | }); 55 | }); 56 | 57 | describe('checking feature', () => { 58 | it('returns false with no rules defined', () => { 59 | const config = { test: { rule: true } }; 60 | const feet = new Feet(config); 61 | const enabled = feet.enabled('test'); 62 | expect(enabled).to.be.false; 63 | }); 64 | 65 | it('returns false when a context does not pass a rule', () => { 66 | const config = { test: { rule: true } }; 67 | const rules = { rule () { return false; }}; 68 | const ctx = { name: 'hamster' }; 69 | 70 | const feet = new Feet(config, rules, ctx); 71 | 72 | const enabled = feet.enabled('test'); 73 | expect(enabled).to.be.false; 74 | }); 75 | 76 | it('returns true when a passes a rule', () => { 77 | const config = { test: { rule: true } }; 78 | const rules = { rule () { return true; }}; 79 | const ctx = { name: 'hamster' }; 80 | 81 | const feet = new Feet(config, rules, ctx); 82 | 83 | const enabled = feet.enabled('test'); 84 | expect(enabled).to.be.true; 85 | }); 86 | 87 | it('returns true when a passes at least one rule', () => { 88 | const config = { 89 | test: { 90 | frule: true, 91 | trule: true, 92 | }, 93 | }; 94 | 95 | const rules = { 96 | frule () { }, 97 | trule () { return true; }, 98 | }; 99 | 100 | const ctx = { name: 'hamster' }; 101 | 102 | const feet = new Feet(config, rules, ctx); 103 | 104 | const enabled = feet.enabled('test'); 105 | expect(enabled).to.be.true; 106 | }); 107 | 108 | it('returns a list of all enabled features', () => { 109 | const config = { 110 | test1: true, 111 | test2: true, 112 | test3: false, 113 | }; 114 | 115 | const feet = new Feet(config); 116 | const enabled = feet.allEnabled(); 117 | expect(enabled).to.deep.equal(['test1', 'test2']); 118 | }); 119 | 120 | it('returns a list of all enabled features for a given context', () => { 121 | const config = { 122 | test1: { isHamster: true }, 123 | test2: { isHamster: true }, 124 | test3: { isHamster: false }, 125 | }; 126 | 127 | const rules = { 128 | isHamster (b) { return (this.name === 'hamster') === b; }, 129 | }; 130 | 131 | const context = { name: 'hamster' }; 132 | 133 | const feet = new Feet(config, rules, context); 134 | const enabled = feet.allEnabled(); 135 | expect(enabled).to.deep.equal(['test1', 'test2']); 136 | }); 137 | 138 | it('returns a list of all disabled features', () => { 139 | const config = { 140 | test1: true, 141 | test2: true, 142 | test3: false, 143 | }; 144 | 145 | const feet = new Feet(config); 146 | const enabled = feet.allDisabled(); 147 | expect(enabled).to.deep.equal(['test3']); 148 | }); 149 | 150 | it('returns a list of all disabled features for a given context', () => { 151 | const config = { 152 | test1: { isHamster: true }, 153 | test2: { isHamster: true }, 154 | test3: { isHamster: false }, 155 | }; 156 | 157 | const rules = { 158 | isHamster (b) { return (this.name === 'hamster') === b; }, 159 | }; 160 | 161 | const context = { name: 'hamster' }; 162 | 163 | const feet = new Feet(config, rules, context); 164 | const enabled = feet.allDisabled(); 165 | expect(enabled).to.deep.equal(['test3']); 166 | }); 167 | 168 | it('supports explicit "or" rules', () => { 169 | const config = { 170 | test1: { 171 | or: [ 172 | { isHamster: true }, 173 | { isDog: true }, 174 | ] 175 | }, 176 | test2: { 177 | or: [ 178 | { isHamster: true }, 179 | { isDog: false }, 180 | ] 181 | }, 182 | test3: { 183 | or: [ 184 | { isHamster: false }, 185 | { isDog: false } 186 | ] 187 | }, 188 | test4: { 189 | or: [ 190 | { isHamster: false }, 191 | { isDog: true }, 192 | ] 193 | }, 194 | }; 195 | 196 | const rules = { 197 | isHamster (b) { return (this.name === 'hamster') === b; }, 198 | isDog (b) { return (this.name === 'dog') === b; }, 199 | }; 200 | 201 | const context = { name: 'hamster' }; 202 | 203 | const feet = new Feet(config, rules, context); 204 | const enabled = feet.allEnabled(); 205 | expect(enabled).to.deep.equal(['test1', 'test2', 'test3']); 206 | }); 207 | 208 | it('supports "and" rules', () => { 209 | const config = { 210 | test1: { 211 | and: [ 212 | { isHamster: true }, 213 | { isDog: true }, 214 | ] 215 | }, 216 | test2: { 217 | and: [ 218 | { isHamster: true }, 219 | { isDog: false }, 220 | ] 221 | }, 222 | test3: { 223 | and: [ 224 | { isHamster: false }, 225 | { isDog: false }, 226 | ] 227 | }, 228 | test4: { 229 | and: [ 230 | { isHamster: false }, 231 | { isDog: true }, 232 | ] 233 | }, 234 | }; 235 | 236 | const rules = { 237 | isHamster (b) { return (this.name === 'hamster') === b; }, 238 | isDog (b) { return (this.name === 'dog') === b; }, 239 | }; 240 | 241 | const context = { name: 'hamster' }; 242 | 243 | const feet = new Feet(config, rules, context); 244 | const enabled = feet.allEnabled(); 245 | expect(enabled).to.deep.equal(['test2']); 246 | }); 247 | 248 | it('supports "not" rules', () => { 249 | const config = { 250 | test1: { not: { isHamster: true } }, 251 | test2: { not: { isHamster: false } }, 252 | test3: { 253 | not: { 254 | isHamster: false, 255 | isDog: false 256 | }, 257 | }, 258 | }; 259 | 260 | const rules = { 261 | isHamster (b) { return (this.name === 'hamster') === b; }, 262 | isDog (b) { return (this.name === 'dog') === b; }, 263 | }; 264 | 265 | const context = { name: 'hamster' }; 266 | 267 | const feet = new Feet(config, rules, context); 268 | const enabled = feet.allEnabled(); 269 | expect(enabled).to.deep.equal(['test2']); 270 | }); 271 | 272 | it('supports nested boolean operator rules', () => { 273 | const config = { 274 | test1: { 275 | and: [ 276 | { isHamster: true }, 277 | { not: { isDog: true } }, 278 | ] 279 | }, 280 | test2: { 281 | and: [ 282 | { 283 | or: [ 284 | { isHamster: true }, 285 | { isHamster: false }, 286 | ], 287 | and: [ 288 | { isHamster: true }, 289 | { isDog: false }, 290 | ], 291 | } 292 | ] 293 | }, 294 | }; 295 | 296 | const rules = { 297 | isHamster (b) { return (this.name === 'hamster') === b; }, 298 | isDog (b) { return (this.name === 'dog') === b; }, 299 | }; 300 | 301 | const context = { name: 'hamster' }; 302 | 303 | const feet = new Feet(config, rules, context); 304 | const enabled = feet.allEnabled(); 305 | expect(enabled).to.deep.equal(['test1', 'test2']); 306 | }); 307 | }); 308 | 309 | describe('static helpers', () => { 310 | it('parses config with defaults', () => { 311 | const config = { 312 | FEATURE_THING: 1, 313 | FEATURE_OTHER_THING: 2, 314 | not_a_feature: 3, 315 | }; 316 | 317 | const expectedConfig = { 318 | thing: 1, 319 | other_thing: 2, 320 | }; 321 | 322 | expect(Feet.parseConfig(config)).deep.equal(expectedConfig); 323 | }); 324 | 325 | it('parses config with custom fn', () => { 326 | const config = { 327 | f_thing: 1, 328 | f_other_thing: 2, 329 | not_a_feature: 3, 330 | }; 331 | 332 | const expectedConfig = { 333 | thing: 1, 334 | other_thing: 2, 335 | }; 336 | 337 | const customparse = function(k, v) { 338 | if (k.indexOf('f_') === -1) { return; } 339 | return { key: k.slice(2), val: v }; 340 | }; 341 | 342 | expect(Feet.parseConfig(config, customparse)).deep.equal(expectedConfig); 343 | }); 344 | }); 345 | 346 | describe('cloning', () => { 347 | it('can clone itself by shallow copy of config, rules, and ctx', () => { 348 | const config = { test: { rule: true } }; 349 | const rules = { rule: sinon.spy() }; 350 | const ctx = { name: 'hamster' }; 351 | 352 | const feet = new Feet(config, rules, ctx); 353 | const newfeet = feet.clone(); 354 | 355 | expect(feet.config).to.not.equal(newfeet.config); 356 | expect(feet.config).to.deep.equal(newfeet.config); 357 | 358 | expect(feet.rules).to.not.equal(newfeet.rules); 359 | expect(feet.rules).to.deep.equal(newfeet.rules); 360 | 361 | expect(feet.ctx).to.not.equal(newfeet.ctx); 362 | expect(feet.ctx).to.deep.equal(newfeet.ctx); 363 | }); 364 | }); 365 | }); 366 | --------------------------------------------------------------------------------