├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── bower.json ├── demo ├── css │ └── main.css ├── data │ └── flags.json ├── images │ └── icon.png ├── index.html ├── scripts │ └── directives.js └── vendor │ ├── angular.min.js │ └── angular.min.js.map ├── dist ├── featureFlags.js └── featureFlags.min.js ├── gulpfile.js ├── index.js ├── package.json ├── src ├── featureFlag.app.js ├── featureFlag.directive.js ├── featureFlagOverrides.directive.js ├── featureFlagOverrides.service.js └── featureFlags.provider.js └── test ├── featureFlag.directive.spec.js ├── featureFlagOverrides.directive.spec.js ├── featureFlagOverrides.service.spec.js ├── featureFlags.provider.spec.js └── vendor └── angular-mocks.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "jasmine": true 5 | }, 6 | "globals": { 7 | "angular": true 8 | }, 9 | "rules": { 10 | // Possible Errors 11 | "comma-dangle": 2, 12 | "no-cond-assign": 2, 13 | "no-console": 0, 14 | "no-constant-condition": 2, 15 | "no-control-regex": 2, 16 | "no-debugger": 2, 17 | "no-dupe-args": 2, 18 | "no-dupe-keys": 2, 19 | "no-duplicate-case": 2, 20 | "no-empty-character-class": 2, 21 | "no-empty": 2, 22 | "no-ex-assign": 2, 23 | "no-extra-boolean-cast": 2, 24 | "no-extra-parens": 0, 25 | "no-extra-semi": 2, 26 | "no-func-assign": 2, 27 | "no-inner-declarations": 2, 28 | "no-invalid-regexp": 2, 29 | "no-irregular-whitespace": 2, 30 | "no-negated-in-lhs": 2, 31 | "no-obj-calls": 2, 32 | "no-regex-spaces": 2, 33 | "no-sparse-arrays": 2, 34 | "no-unexpected-multiline": 2, 35 | "no-unreachable": 2, 36 | "use-isnan": 2, 37 | "valid-jsdoc": 0, 38 | "valid-typeof": 2, 39 | 40 | // Best Practices 41 | "accessor-pairs": 0, 42 | "block-scoped-var": 2, 43 | "complexity": 0, 44 | "consistent-return": 2, 45 | "curly": [2, "all"], 46 | "default-case": 2, 47 | "dot-notation": 2, 48 | "dot-location": [2, "property"], 49 | "eqeqeq": 2, 50 | "guard-for-in": 2, 51 | "no-alert": 2, 52 | "no-caller": 2, 53 | "no-div-regex": 2, 54 | "no-else-return": 2, 55 | "no-empty-label": 2, 56 | "no-eq-null": 2, 57 | "no-eval": 2, 58 | "no-extend-native": 2, 59 | "no-extra-bind": 2, 60 | "no-fallthrough": 2, 61 | "no-floating-decimal": 2, 62 | "no-implicit-coercion": 2, 63 | "no-implied-eval": 2, 64 | "no-invalid-this": 2, 65 | "no-iterator": 2, 66 | "no-labels": 2, 67 | "no-lone-blocks": 2, 68 | "no-loop-func": 2, 69 | "no-multi-spaces": 2, 70 | "no-multi-str": 2, 71 | "no-native-reassign": 2, 72 | "no-new-func": 2, 73 | "no-new-wrappers": 2, 74 | "no-new": 2, 75 | "no-octal-escape": 2, 76 | "no-octal": 2, 77 | "no-param-reassign": 2, 78 | "no-process-env": 0, 79 | "no-proto": 2, 80 | "no-redeclare": 2, 81 | "no-return-assign": 2, 82 | "no-script-url": 2, 83 | "no-self-compare": 2, 84 | "no-sequences": 2, 85 | "no-throw-literal": 2, 86 | "no-unused-expressions": 2, 87 | "no-useless-call": 2, 88 | "no-void": 2, 89 | "no-warning-comments": 2, 90 | "no-with": 2, 91 | "radix": 2, 92 | "vars-on-top": 2, 93 | "wrap-iife": 2, 94 | "yoda": 2, 95 | 96 | // Strict Mode 97 | "strict": 0, 98 | 99 | // Variables 100 | "init-declarations": 0, 101 | "no-catch-shadow": 2, 102 | "no-delete-var": 2, 103 | "no-label-var": 2, 104 | "no-shadow-restricted-names": 2, 105 | "no-shadow": 2, 106 | "no-undef-init": 2, 107 | "no-undef": 2, 108 | "no-undefined": 2, 109 | "no-unused-vars": 2, 110 | "no-use-before-define": 2, 111 | 112 | // Node.js 113 | "callback-return": 0, 114 | "handle-callback-err": 2, 115 | "no-mixed-requires": 0, 116 | "no-new-require": 2, 117 | "no-path-concat": 2, 118 | "no-process-exit": 2, 119 | "no-restricted-modules": 2, 120 | "no-sync": 2, 121 | 122 | // Stylistic Issues 123 | "block-spacing": [2, "always"], 124 | "brace-style": [2, "1tbs"], 125 | "comma-spacing": [2, { "before": false, "after": true }], 126 | "comma-style": [2, "last"], 127 | "eol-last": 2, 128 | "func-style": 0, 129 | "indent": [2, 2, { "SwitchCase": 1 }], 130 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 131 | "linebreak-style": [2, "unix"], 132 | "new-cap": 2, 133 | "new-parens": 2, 134 | "no-lonely-if": 2, 135 | "no-mixed-spaces-and-tabs": 2, 136 | "no-multiple-empty-lines": [2, { "max": 1 }], 137 | "no-nested-ternary": 2, 138 | "no-spaced-func": 2, 139 | "no-trailing-spaces": 2, 140 | "no-unneeded-ternary": 2, 141 | "object-curly-spacing": [2, "always"], 142 | "operator-linebreak": [2, "after"], 143 | "padded-blocks": [2, "never"], 144 | "quotes": [2, "single"], 145 | "semi-spacing": [2, { "before": false, "after": true }], 146 | "semi": [2, "always"], 147 | "space-after-keywords": [2, "always"], 148 | "space-before-blocks": [2, "always"], 149 | "space-before-function-paren": [2, "never"], 150 | "space-in-parens": [2, "never"], 151 | "space-infix-ops": 2, 152 | "space-return-throw-case": 2, 153 | "spaced-comment": [2, "always"] 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/coverage 3 | demo/scripts/featureFlags.min.js 4 | .idea 5 | bower_components 6 | coverage 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | coverage 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.10 5 | 6 | after_script: 7 | - npm run coveralls -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## PSA: This repo is no longer maintained. Feel free to fork it to suit your use case. 2 | 3 | [![Build Status](https://img.shields.io/travis/michaeltaranto/angular-feature-flags/master.svg?style=flat-square)](https://travis-ci.org/michaeltaranto/angular-feature-flags) 4 | [![Coverage Status](https://img.shields.io/coveralls/michaeltaranto/angular-feature-flags.svg?style=flat-square)](https://coveralls.io/github/michaeltaranto/angular-feature-flags?branch=master) 5 | [![npm](https://img.shields.io/npm/v/angular-feature-flags.svg?style=flat-square)](https://www.npmjs.com/package/angular-feature-flags) 6 | 7 | ## angular-feature-flags 8 | 9 | An AngularJS module that allows you to control when you release new features in your app by putting them behind feature flags/switches. **This module only supports Angular v1.2 and up.** 10 | 11 | 12 | ### The idea 13 | 14 | Abstracting your application functionality into small chunks and implementing them as loosely coupled directives. This allows you to completely remove sections of your application by simply toggling a single dom element. 15 | 16 | 17 | ### How it works 18 | 19 | The basic premise is you write your feature and wrap it up in a directive, then where you implement that directive in your markup you add the **feature-flag** directive to the same element. You can then pass the **key** of the flag to this directive to resolve whether of not this feature should be enabled. 20 | 21 | The module pulls a json file down which defines the feature flags and which ones are active. If enabled angular will process the directive as normal, if disabled angular will remove the element from the dom and not compile or execute any other directives is has. 22 | 23 | You can then add the **override** panel to your app and turn individual features on override the server values, saving the override in local storage which is useful in development. 24 | 25 | 26 | ### Flag data 27 | 28 | The flag data that drives the feature flag service is a json format. Below is an example: 29 | ```json 30 | [ 31 | { "key": "...", "active": "...", "name": "...", "description": "..." }, 32 | ... 33 | ] 34 | ``` 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
keyUnique key that is used from the markup to resolve whether a flag is active or not.
activeBoolean value for enabling/disabling the feature
nameA short name of the flag (only visible in the list of flags)
descriptionA long description of the flag to further explain the feature being toggled (only visible in the list of flags)
53 | 54 | 55 | ### Setting flag data 56 | 57 | Flag data can be set via the `featureFlags` service using the `set` method. This currently accepts either a [HttpPromise](https://docs.angularjs.org/api/ng/service/$http) or a regular [Promise](https://docs.angularjs.org/api/ng/service/$q). The promise must resolve to a valid collection of [flag data](#flag-data). 58 | 59 | For example, if you were loading your flag data from a remote JSON file: 60 | 61 | ```js 62 | var myApp = angular.module('app', ['feature-flags']); 63 | 64 | myApp.run(function(featureFlags, $http) { 65 | featureFlags.set($http.get('/data/flags.json')); 66 | }); 67 | ``` 68 | 69 | ### Setting flag data on config phase (≥ v1.1.0) 70 | 71 | From version v1.1.0 you can also initialize the feature flags in the config phase of your application: 72 | 73 | ```js 74 | var myApp = angular.module('app', ['feature-flags']); 75 | 76 | myApp.config(function(featureFlagsProvider) { 77 | featureFlagsProvider.setInitialFlags([ 78 | { "key": "...", "active": "...", "name": "...", "description": "..." }, 79 | ]); 80 | }); 81 | ``` 82 | 83 | ### Toggling elements 84 | 85 | The `feature-flag` directive allows simple toggling of elements based on feature flags, e.g: 86 | 87 | ```html 88 |
89 | I will be visible if 'myFlag' is enabled 90 |
91 | ``` 92 | 93 | If you need to *hide* elements when a flag is enabled, add the `feature-flag-hide` attribute, e.g: 94 | 95 | ```html 96 |
97 | I will *NOT* be visible if 'myFlag' is enabled 98 |
99 | ``` 100 | 101 | ### Running the demo 102 | 103 | Running the demo is easy assuming you have Gulp installed: 104 | 105 | - Checkout the project 106 | - Switch to the directory 107 | - Run 'gulp demo' 108 | 109 | Should launch the demo in your default browser 110 | 111 | 112 | ### Running the unit test 113 | 114 | This relies on Gulp also obviously, to run the test suite: 115 | 116 | - Checkout the project 117 | - Switch to the directory 118 | - Run 'gulp test' 119 | 120 | 121 | ## License 122 | 123 | [MIT](http://michaeltaranto.mit-license.org) 124 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-feature-flags", 3 | "version": "1.6.1", 4 | "authors": [ 5 | { 6 | "name": "Michael Taranto", 7 | "homepage": "https://github.com/mjt01" 8 | }, 9 | { 10 | "name": "Mark Dalgleish", 11 | "homepage": "https://github.com/markdalgleish" 12 | } 13 | ], 14 | "dependencies": { 15 | "angular": ">=1.2" 16 | }, 17 | "description": "Feature Flag module for Angular JS apps", 18 | "homepage": "https://github.com/mjt01/angular-feature-flags", 19 | "ignore": [ 20 | "*", 21 | "!dist/**/*", 22 | "!README.md" 23 | ], 24 | "main": [ 25 | "dist/featureFlags.js" 26 | ], 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /demo/css/main.css: -------------------------------------------------------------------------------- 1 | ::selection { background: transparent; } 2 | body { background-color: #EAEAEA; font-size: 12px; line-height: 1.4em; font-family: Helvetica,Helvetica Neue,Arial; overflow:hidden; } 3 | h1 { margin: 20px; font-size: 18px; font-weight: normal; } 4 | h2 { margin-top: 0; margin-bottom: 10px; font-size: 14px; font-weight: normal; } 5 | .main, .flagContainer { 6 | position: absolute; top: 10%; bottom: 10%; 7 | background-color: #FFFFFF; 8 | border: 1px solid #CFCFCF; 9 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 10 | transition: all 0.4s linear; 11 | } 12 | .main { right: 20%; left: 20%; min-width: 500px; background-color: #61a1bc; } 13 | .main h1 { text-align: center; color: #FFFFFF; margin: 40px; font-size: 30pt; text-shadow: 0 3px 0 rgba(0,0,0,0.1); } 14 | .flagContainer { left: -1000px; width: 600px; } 15 | 16 | .toggle .main { left: 700px; opacity: 0.4; } 17 | .toggle .flagContainer { left: 0; } 18 | 19 | .panel { width: 70%; background-color: #8EC16D; text-align: center; margin: 20px auto; font-size: 17pt; padding: 20px; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); font-family: sans-serif; color: #FFF; } 20 | .panel:hover { cursor: pointer; background-color: #9DC980; } 21 | .panel.selected { background-color: #EE7777; } 22 | 23 | .toggles { height: 20px; width: 20px; background: transparent url('../images/icon.png') no-repeat scroll 0 0; cursor: pointer; } 24 | 25 | .feature-flags {} 26 | .feature-flags .feature-flags-flag { padding: 10px 30px; transition: all 0.2s linear; } 27 | .feature-flags .feature-flags-name { font-size: 1.2em; float:left; } 28 | .feature-flags .feature-flags-switch { float: right; padding: 2px 10px; cursor: pointer; } 29 | .feature-flags .feature-flags-switch.active { font-weight: bold; border: 1px solid currentcolor; padding: 1px 9px; } 30 | .feature-flags .feature-flags-desc { color: #AAAAAA; clear: both; margin: 0 20px; } -------------------------------------------------------------------------------- /demo/data/flags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "activityFeed", 4 | "active": true, 5 | "name": "Activity Feed", 6 | "description": "Shows the users activity feed as a stream of events that have occurred." 7 | }, 8 | { 9 | "key": "messages", 10 | "active": true, 11 | "name": "Messaging", 12 | "description": "Messaging module that allows users to send and receive messages to one another." 13 | }, 14 | { 15 | "key": "userProfile", 16 | "active": true, 17 | "name": "User Profile", 18 | "description": "Displays all the information about the logged in user's profile." 19 | }, 20 | { 21 | "key": "settings", 22 | "active": false, 23 | "name": "Settings", 24 | "description": "Configure the user's settings and preferences for the app." 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /demo/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeltaranto/angular-feature-flags/16d22371af4a2dfe830f9ac52845995ac8baccfb/demo/images/icon.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

My App

18 |
19 |
20 |
21 |
22 |
23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /demo/scripts/directives.js: -------------------------------------------------------------------------------- 1 | angular.module('my-app') 2 | .directive('activityFeed', function() { 3 | return { 4 | restrict: 'A', 5 | scope: {}, 6 | template: '
Activity Feed
', 7 | replace: true 8 | }; 9 | }) 10 | .directive('messaging', function() { 11 | return { 12 | restrict: 'A', 13 | scope: {}, 14 | template: '
Messaging
', 15 | replace: true 16 | }; 17 | }) 18 | .directive('userProfile', function() { 19 | return { 20 | restrict: 'A', 21 | scope: {}, 22 | template: '
User Profile
', 23 | replace: true 24 | }; 25 | }) 26 | .directive('settings', function() { 27 | return { 28 | restrict: 'A', 29 | scope: {}, 30 | template: '
Settings
', 31 | replace: true 32 | }; 33 | }) 34 | .run(function(featureFlags, $http) { 35 | featureFlags.set($http.get('../data/flags.json')); 36 | }); 37 | -------------------------------------------------------------------------------- /dist/featureFlags.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Angular Feature Flags v1.6.1 3 | * 4 | * © 2017, Michael Taranto 5 | */ 6 | 7 | (function(){ 8 | angular.module('feature-flags', []); 9 | 10 | angular.module('feature-flags').directive('featureFlag', ['featureFlags', '$interpolate', function(featureFlags, $interpolate) { 11 | return { 12 | transclude: 'element', 13 | priority: 599, 14 | terminal: true, 15 | restrict: 'A', 16 | $$tlb: true, 17 | compile: function featureFlagCompile(tElement, tAttrs) { 18 | var hasHideAttribute = 'featureFlagHide' in tAttrs; 19 | 20 | tElement[0].textContent = ' featureFlag: ' + tAttrs.featureFlag + ' is ' + (hasHideAttribute ? 'on' : 'off') + ' '; 21 | 22 | return function featureFlagPostLink($scope, element, attrs, ctrl, $transclude) { 23 | var featureEl, childScope; 24 | $scope.$watch(function featureFlagWatcher() { 25 | var featureFlag = $interpolate(attrs.featureFlag)($scope); 26 | return featureFlags.isOn(featureFlag); 27 | }, function featureFlagChanged(isEnabled) { 28 | var showElement = hasHideAttribute ? !isEnabled : isEnabled; 29 | 30 | if (showElement) { 31 | childScope = $scope.$new(); 32 | $transclude(childScope, function(clone) { 33 | featureEl = clone; 34 | element.after(featureEl).remove(); 35 | }); 36 | } else { 37 | if (childScope) { 38 | childScope.$destroy(); 39 | childScope = null; 40 | } 41 | if (featureEl) { 42 | featureEl.after(element).remove(); 43 | featureEl = null; 44 | } 45 | } 46 | }); 47 | }; 48 | } 49 | }; 50 | }]); 51 | 52 | angular.module('feature-flags').directive('featureFlagOverrides', ['featureFlags', function(featureFlags) { 53 | return { 54 | restrict: 'A', 55 | link: function postLink($scope) { 56 | $scope.flags = featureFlags.get(); 57 | 58 | $scope.isOn = featureFlags.isOn; 59 | $scope.isOverridden = featureFlags.isOverridden; 60 | $scope.enable = featureFlags.enable; 61 | $scope.disable = featureFlags.disable; 62 | $scope.reset = featureFlags.reset; 63 | $scope.isOnByDefault = featureFlags.isOnByDefault; 64 | }, 65 | template: '
' + 66 | '

Feature Flags

' + 67 | '
' + 68 | '
{{flag.name || flag.key}}
' + 69 | '
ON
' + 70 | '
OFF
' + 71 | '
DEFAULT ({{isOnByDefault(flag.key) ? \'ON\' : \'OFF\'}})
' + 72 | '
{{flag.description}}
' + 73 | '
' + 74 | '
', 75 | replace: true 76 | }; 77 | }]); 78 | 79 | angular.module('feature-flags').service('featureFlagOverrides', ['$rootElement', function($rootElement) { 80 | var appName = $rootElement.attr('ng-app'), 81 | keyPrefix = 'featureFlags.' + appName + '.', 82 | 83 | localStorageAvailable = (function() { 84 | try { 85 | localStorage.setItem('featureFlags.availableTest', 'test'); 86 | localStorage.removeItem('featureFlags.availableTest'); 87 | return true; 88 | } catch (e) { 89 | return false; 90 | } 91 | }()), 92 | 93 | prefixedKeyFor = function(flagName) { 94 | return keyPrefix + flagName; 95 | }, 96 | 97 | isPrefixedKey = function(key) { 98 | return key.indexOf(keyPrefix) === 0; 99 | }, 100 | 101 | set = function(value, flagName) { 102 | if (localStorageAvailable) { 103 | localStorage.setItem(prefixedKeyFor(flagName), value); 104 | } 105 | }, 106 | 107 | get = function(flagName) { 108 | if (localStorageAvailable) { 109 | return localStorage.getItem(prefixedKeyFor(flagName)); 110 | } 111 | }, 112 | 113 | remove = function(flagName) { 114 | if (localStorageAvailable) { 115 | localStorage.removeItem(prefixedKeyFor(flagName)); 116 | } 117 | }; 118 | 119 | return { 120 | isPresent: function(key) { 121 | var value = get(key); 122 | return typeof value !== 'undefined' && value !== null; 123 | }, 124 | get: get, 125 | set: function(flag, value) { 126 | if (angular.isObject(flag)) { 127 | angular.forEach(flag, set); 128 | } else { 129 | set(value, flag); 130 | } 131 | }, 132 | remove: remove, 133 | reset: function() { 134 | var key; 135 | if (localStorageAvailable) { 136 | for (key in localStorage) { 137 | if (isPrefixedKey(key)) { 138 | localStorage.removeItem(key); 139 | } 140 | } 141 | } 142 | } 143 | }; 144 | }]); 145 | 146 | function FeatureFlags($q, featureFlagOverrides, initialFlags) { 147 | var serverFlagCache = {}, 148 | flags = [], 149 | 150 | resolve = function(val) { 151 | var deferred = $q.defer(); 152 | deferred.resolve(val); 153 | return deferred.promise; 154 | }, 155 | 156 | isOverridden = function(key) { 157 | return featureFlagOverrides.isPresent(key); 158 | }, 159 | 160 | isOn = function(key) { 161 | return isOverridden(key) ? featureFlagOverrides.get(key) === 'true' : serverFlagCache[key]; 162 | }, 163 | 164 | isOnByDefault = function(key) { 165 | return serverFlagCache[key]; 166 | }, 167 | 168 | updateFlagsAndGetAll = function(newFlags) { 169 | newFlags.forEach(function(flag) { 170 | serverFlagCache[flag.key] = flag.active; 171 | flag.active = isOn(flag.key); 172 | }); 173 | angular.copy(newFlags, flags); 174 | 175 | return flags; 176 | }, 177 | 178 | updateFlagsWithPromise = function(promise) { 179 | return promise.then(function(value) { 180 | return updateFlagsAndGetAll(value.data || value); 181 | }); 182 | }, 183 | 184 | get = function() { 185 | return flags; 186 | }, 187 | 188 | set = function(newFlags) { 189 | return angular.isArray(newFlags) ? resolve(updateFlagsAndGetAll(newFlags)) : updateFlagsWithPromise(newFlags); 190 | }, 191 | 192 | enable = function(flag) { 193 | flag.active = true; 194 | featureFlagOverrides.set(flag.key, true); 195 | }, 196 | 197 | disable = function(flag) { 198 | flag.active = false; 199 | featureFlagOverrides.set(flag.key, false); 200 | }, 201 | 202 | reset = function(flag) { 203 | flag.active = serverFlagCache[flag.key]; 204 | featureFlagOverrides.remove(flag.key); 205 | }, 206 | 207 | init = function() { 208 | if (initialFlags) { 209 | set(initialFlags); 210 | } 211 | }; 212 | init(); 213 | 214 | return { 215 | set: set, 216 | get: get, 217 | enable: enable, 218 | disable: disable, 219 | reset: reset, 220 | isOn: isOn, 221 | isOnByDefault: isOnByDefault, 222 | isOverridden: isOverridden 223 | }; 224 | } 225 | 226 | angular.module('feature-flags').provider('featureFlags', function() { 227 | var initialFlags = []; 228 | 229 | this.setInitialFlags = function(flags) { 230 | initialFlags = flags; 231 | }; 232 | 233 | this.$get = ['$q', 'featureFlagOverrides', function($q, featureFlagOverrides) { 234 | return new FeatureFlags($q, featureFlagOverrides, initialFlags); 235 | }]; 236 | }); 237 | 238 | }()); -------------------------------------------------------------------------------- /dist/featureFlags.min.js: -------------------------------------------------------------------------------- 1 | /*! Angular Feature Flags v1.6.1 © 2017 Michael Taranto */ 2 | !function(){function e(e,a,t){var r={},n=[],i=function(a){var t=e.defer();return t.resolve(a),t.promise},l=function(e){return a.isPresent(e)},f=function(e){return l(e)?"true"===a.get(e):r[e]},u=function(e){return r[e]},s=function(e){return e.forEach(function(e){r[e.key]=e.active,e.active=f(e.key)}),angular.copy(e,n),n},c=function(e){return e.then(function(e){return s(e.data||e)})},g=function(){return n},o=function(e){return angular.isArray(e)?i(s(e)):c(e)},d=function(e){e.active=!0,a.set(e.key,!0)},v=function(e){e.active=!1,a.set(e.key,!1)},y=function(e){e.active=r[e.key],a.remove(e.key)},m=function(){t&&o(t)};return m(),{set:o,get:g,enable:d,disable:v,reset:y,isOn:f,isOnByDefault:u,isOverridden:l}}angular.module("feature-flags",[]),angular.module("feature-flags").directive("featureFlag",["featureFlags","$interpolate",function(e,a){return{transclude:"element",priority:599,terminal:!0,restrict:"A",$$tlb:!0,compile:function(t,r){var n="featureFlagHide"in r;return t[0].textContent=" featureFlag: "+r.featureFlag+" is "+(n?"on":"off")+" ",function(t,r,i,l,f){var u,s;t.$watch(function(){var r=a(i.featureFlag)(t);return e.isOn(r)},function(e){var a=n?!e:e;a?(s=t.$new(),f(s,function(e){u=e,r.after(u).remove()})):(s&&(s.$destroy(),s=null),u&&(u.after(r).remove(),u=null))})}}}}]),angular.module("feature-flags").directive("featureFlagOverrides",["featureFlags",function(e){return{restrict:"A",link:function(a){a.flags=e.get(),a.isOn=e.isOn,a.isOverridden=e.isOverridden,a.enable=e.enable,a.disable=e.disable,a.reset=e.reset,a.isOnByDefault=e.isOnByDefault},template:'

Feature Flags

{{flag.name || flag.key}}
ON
OFF
DEFAULT ({{isOnByDefault(flag.key) ? \'ON\' : \'OFF\'}})
{{flag.description}}
',replace:!0}}]),angular.module("feature-flags").service("featureFlagOverrides",["$rootElement",function(e){var a=e.attr("ng-app"),t="featureFlags."+a+".",r=function(){try{return localStorage.setItem("featureFlags.availableTest","test"),localStorage.removeItem("featureFlags.availableTest"),!0}catch(e){return!1}}(),n=function(e){return t+e},i=function(e){return 0===e.indexOf(t)},l=function(e,a){r&&localStorage.setItem(n(a),e)},f=function(e){return r?localStorage.getItem(n(e)):void 0},u=function(e){r&&localStorage.removeItem(n(e))};return{isPresent:function(e){var a=f(e);return"undefined"!=typeof a&&null!==a},get:f,set:function(e,a){angular.isObject(e)?angular.forEach(e,l):l(a,e)},remove:u,reset:function(){var e;if(r)for(e in localStorage)i(e)&&localStorage.removeItem(e)}}}]),angular.module("feature-flags").provider("featureFlags",function(){var a=[];this.setInitialFlags=function(e){a=e},this.$get=["$q","featureFlagOverrides",function(t,r){return new e(t,r,a)}]})}(); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | //-------------------------------- 2 | // MODULES 3 | //-------------------------------- 4 | var gulp = require('gulp'), 5 | eslint = require('gulp-eslint'), 6 | connect = require('gulp-connect'), 7 | opn = require('opn'), 8 | rename = require('gulp-rename'), 9 | uglify = require('gulp-uglify'), 10 | header = require('gulp-header'), 11 | wrap = require('gulp-wrap'), 12 | concat = require('gulp-concat'), 13 | clean = require('gulp-clean'), 14 | ngannotate = require('gulp-ng-annotate'), 15 | karma = require('gulp-karma'), 16 | ghpages = require('gh-pages'), 17 | path = require('path'), 18 | gutil = require('gulp-util'), 19 | coveralls = require('gulp-coveralls'), 20 | pkg = require('./package.json'), 21 | 22 | //-------------------------------- 23 | // HELPERS 24 | //-------------------------------- 25 | karmaConfig = function(action) { 26 | return { 27 | frameworks: ['jasmine'], 28 | browsers: ['PhantomJS'], 29 | reporters: ['progress', 'coverage'], 30 | preprocessors: { 31 | 'src/*.js': ['coverage'] 32 | }, 33 | coverageReporter: { 34 | reporters: [{ 35 | type: 'html', 36 | dir: 'test/coverage/' 37 | }, { 38 | type: 'lcov', 39 | dir: 'test/coverage/' 40 | }] 41 | }, 42 | action: action 43 | }; 44 | }, 45 | TEST_FILES = 'test/**/*.spec.js', 46 | SRC_FILES = 'src/*.js', 47 | KARMA_FILES = [ 48 | 'demo/vendor/angular.min.js', 49 | 'test/vendor/angular-mocks.js', 50 | SRC_FILES, 51 | TEST_FILES 52 | ], 53 | PORT = 9999; 54 | 55 | //-------------------------------- 56 | // TASKS 57 | //-------------------------------- 58 | 59 | gulp.task('lint', function() { 60 | return gulp.src([ 61 | 'demo/scripts/directives.js', 62 | SRC_FILES, 63 | TEST_FILES 64 | ]) 65 | .pipe(eslint({ 66 | configFile: '.eslintrc.js' 67 | })) 68 | .pipe(eslint.format()) 69 | .pipe(eslint.failOnError()); 70 | }); 71 | 72 | gulp.task('test', ['clean'], function() { 73 | return gulp.src(KARMA_FILES) 74 | .pipe(karma(karmaConfig('run'))) 75 | .on('error', function(err) { 76 | throw err; 77 | }); 78 | }); 79 | 80 | gulp.task('clean', function() { 81 | return gulp.src('test/coverage') 82 | .pipe(clean()); 83 | }); 84 | 85 | gulp.task('coveralls', ['test'], function() { 86 | return gulp.src(['test/coverage/**/lcov.info']) 87 | .pipe(coveralls()); 88 | }); 89 | 90 | gulp.task('connect', function() { 91 | connect.server({ 92 | root: 'demo', 93 | port: PORT, 94 | livereload: true 95 | }); 96 | }); 97 | 98 | gulp.task('server', ['connect'], function() { 99 | opn("http://localhost:" + PORT); 100 | }); 101 | 102 | gulp.task('reload', function() { 103 | return gulp.src('demo/**/*.*') 104 | .pipe(connect.reload()); 105 | }); 106 | 107 | gulp.task('build', function() { 108 | return gulp.src(SRC_FILES) 109 | .pipe(concat('featureFlags.js')) 110 | .pipe(wrap('(function(){\n<%= contents %>\n}());')) 111 | .pipe(header([ 112 | '/*!', 113 | ' * <%= title %> v<%= version %>', 114 | ' *', 115 | ' * © <%= new Date().getFullYear() %>, <%= author.name %>', 116 | ' */\n\n' 117 | ].join('\n'), pkg)) 118 | .pipe(ngannotate({ 119 | add: true, 120 | single_quotes: true 121 | })) 122 | .pipe(gulp.dest('dist/')) 123 | .pipe(rename('featureFlags.min.js')) 124 | .pipe(uglify()) 125 | .pipe(header([ 126 | '/*! <%= title %> v<%= version %> © <%= new Date().getFullYear() %> <%= author.name %> */\n' 127 | ].join(''), pkg)) 128 | .pipe(gulp.dest('dist/')) 129 | .pipe(gulp.dest('demo/scripts')); 130 | }); 131 | 132 | gulp.task('dev', ['build', 'server'], function() { 133 | gulp.watch(['demo/**/*.*'], ['reload']); 134 | gulp.watch(['demo/scripts/*.js', TEST_FILES], ['lint']); 135 | gulp.watch(SRC_FILES, ['lint', 'build']); 136 | gulp.src(KARMA_FILES) 137 | .pipe(karma(karmaConfig('watch'))); 138 | }); 139 | 140 | gulp.task('deploy', ['build'], function(done) { 141 | ghpages.publish(path.join(__dirname, 'demo'), { 142 | logger: gutil.log 143 | }, done); 144 | }); 145 | 146 | gulp.task('precommit', ['lint', 'test', 'build']); 147 | gulp.task('demo', ['build', 'server']); 148 | gulp.task('default', ['precommit']); 149 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./dist/featureFlags.js'); 4 | module.exports = 'feature-flags'; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-feature-flags", 3 | "title": "Angular Feature Flags", 4 | "version": "1.6.1", 5 | "description": "Feature Flag module for Angular JS apps", 6 | "main": "index.js", 7 | "keywords": [ 8 | "angular", 9 | "feature flag", 10 | "feature switch" 11 | ], 12 | "homepage": "https://github.com/michaeltaranto/angular-feature-flags", 13 | "bugs": "https://github.com/michaeltaranto/angular-feature-flags/issues", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/michaeltaranto/angular-feature-flags.git" 17 | }, 18 | "author": { 19 | "name": "Michael Taranto", 20 | "homepage": "https://github.com/michaeltaranto" 21 | }, 22 | "contributors": [ 23 | { 24 | "name": "Mark Dalgleish", 25 | "homepage": "https://github.com/markdalgleish" 26 | } 27 | ], 28 | "licenses": [ 29 | { 30 | "type": "MIT", 31 | "url": "http://michaeltaranto.mit-license.org" 32 | } 33 | ], 34 | "engines": { 35 | "node": ">=0.10.0" 36 | }, 37 | "scripts": { 38 | "test": "gulp", 39 | "coveralls": "gulp coveralls" 40 | }, 41 | "devDependencies": { 42 | "gh-pages": "^0.2.0", 43 | "gulp": "^3.6.0", 44 | "gulp-clean": "^0.2.4", 45 | "gulp-concat": "^2.2.0", 46 | "gulp-connect": "^2.0.5", 47 | "gulp-coveralls": "^0.1.2", 48 | "gulp-eslint": "^1.0.0", 49 | "gulp-header": "^1.0.2", 50 | "gulp-karma": "0.0.4", 51 | "gulp-ng-annotate": "^0.3.3", 52 | "gulp-rename": "^1.2.0", 53 | "gulp-uglify": "^0.2.1", 54 | "gulp-util": "^3.0.1", 55 | "gulp-wrap": "^0.3.0", 56 | "karma": "^0.12.9", 57 | "karma-coverage": "~0.1.5", 58 | "karma-jasmine": "^0.1.5", 59 | "karma-phantomjs-launcher": "^0.1.4", 60 | "opn": "^0.1.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/featureFlag.app.js: -------------------------------------------------------------------------------- 1 | angular.module('feature-flags', []); 2 | -------------------------------------------------------------------------------- /src/featureFlag.directive.js: -------------------------------------------------------------------------------- 1 | angular.module('feature-flags').directive('featureFlag', function(featureFlags, $interpolate) { 2 | return { 3 | transclude: 'element', 4 | priority: 599, 5 | terminal: true, 6 | restrict: 'A', 7 | $$tlb: true, 8 | compile: function featureFlagCompile(tElement, tAttrs) { 9 | var hasHideAttribute = 'featureFlagHide' in tAttrs; 10 | 11 | tElement[0].textContent = ' featureFlag: ' + tAttrs.featureFlag + ' is ' + (hasHideAttribute ? 'on' : 'off') + ' '; 12 | 13 | return function featureFlagPostLink($scope, element, attrs, ctrl, $transclude) { 14 | var featureEl, childScope; 15 | $scope.$watch(function featureFlagWatcher() { 16 | var featureFlag = $interpolate(attrs.featureFlag)($scope); 17 | return featureFlags.isOn(featureFlag); 18 | }, function featureFlagChanged(isEnabled) { 19 | var showElement = hasHideAttribute ? !isEnabled : isEnabled; 20 | 21 | if (showElement) { 22 | childScope = $scope.$new(); 23 | $transclude(childScope, function(clone) { 24 | featureEl = clone; 25 | element.after(featureEl).remove(); 26 | }); 27 | } else { 28 | if (childScope) { 29 | childScope.$destroy(); 30 | childScope = null; 31 | } 32 | if (featureEl) { 33 | featureEl.after(element).remove(); 34 | featureEl = null; 35 | } 36 | } 37 | }); 38 | }; 39 | } 40 | }; 41 | }); 42 | -------------------------------------------------------------------------------- /src/featureFlagOverrides.directive.js: -------------------------------------------------------------------------------- 1 | angular.module('feature-flags').directive('featureFlagOverrides', function(featureFlags) { 2 | return { 3 | restrict: 'A', 4 | link: function postLink($scope) { 5 | $scope.flags = featureFlags.get(); 6 | 7 | $scope.isOn = featureFlags.isOn; 8 | $scope.isOverridden = featureFlags.isOverridden; 9 | $scope.enable = featureFlags.enable; 10 | $scope.disable = featureFlags.disable; 11 | $scope.reset = featureFlags.reset; 12 | $scope.isOnByDefault = featureFlags.isOnByDefault; 13 | }, 14 | template: '
' + 15 | '

Feature Flags

' + 16 | '
' + 17 | '
{{flag.name || flag.key}}
' + 18 | '
ON
' + 19 | '
OFF
' + 20 | '
DEFAULT ({{isOnByDefault(flag.key) ? \'ON\' : \'OFF\'}})
' + 21 | '
{{flag.description}}
' + 22 | '
' + 23 | '
', 24 | replace: true 25 | }; 26 | }); 27 | -------------------------------------------------------------------------------- /src/featureFlagOverrides.service.js: -------------------------------------------------------------------------------- 1 | angular.module('feature-flags').service('featureFlagOverrides', function($rootElement) { 2 | var appName = $rootElement.attr('ng-app'), 3 | keyPrefix = 'featureFlags.' + appName + '.', 4 | 5 | localStorageAvailable = (function() { 6 | try { 7 | localStorage.setItem('featureFlags.availableTest', 'test'); 8 | localStorage.removeItem('featureFlags.availableTest'); 9 | return true; 10 | } catch (e) { 11 | return false; 12 | } 13 | }()), 14 | 15 | prefixedKeyFor = function(flagName) { 16 | return keyPrefix + flagName; 17 | }, 18 | 19 | isPrefixedKey = function(key) { 20 | return key.indexOf(keyPrefix) === 0; 21 | }, 22 | 23 | set = function(value, flagName) { 24 | if (localStorageAvailable) { 25 | localStorage.setItem(prefixedKeyFor(flagName), value); 26 | } 27 | }, 28 | 29 | get = function(flagName) { 30 | if (localStorageAvailable) { 31 | return localStorage.getItem(prefixedKeyFor(flagName)); 32 | } 33 | }, 34 | 35 | remove = function(flagName) { 36 | if (localStorageAvailable) { 37 | localStorage.removeItem(prefixedKeyFor(flagName)); 38 | } 39 | }; 40 | 41 | return { 42 | isPresent: function(key) { 43 | var value = get(key); 44 | return typeof value !== 'undefined' && value !== null; 45 | }, 46 | get: get, 47 | set: function(flag, value) { 48 | if (angular.isObject(flag)) { 49 | angular.forEach(flag, set); 50 | } else { 51 | set(value, flag); 52 | } 53 | }, 54 | remove: remove, 55 | reset: function() { 56 | var key; 57 | if (localStorageAvailable) { 58 | for (key in localStorage) { 59 | if (isPrefixedKey(key)) { 60 | localStorage.removeItem(key); 61 | } 62 | } 63 | } 64 | } 65 | }; 66 | }); 67 | -------------------------------------------------------------------------------- /src/featureFlags.provider.js: -------------------------------------------------------------------------------- 1 | function FeatureFlags($q, featureFlagOverrides, initialFlags) { 2 | var serverFlagCache = {}, 3 | flags = [], 4 | 5 | resolve = function(val) { 6 | var deferred = $q.defer(); 7 | deferred.resolve(val); 8 | return deferred.promise; 9 | }, 10 | 11 | isOverridden = function(key) { 12 | return featureFlagOverrides.isPresent(key); 13 | }, 14 | 15 | isOn = function(key) { 16 | return isOverridden(key) ? featureFlagOverrides.get(key) === 'true' : serverFlagCache[key]; 17 | }, 18 | 19 | isOnByDefault = function(key) { 20 | return serverFlagCache[key]; 21 | }, 22 | 23 | updateFlagsAndGetAll = function(newFlags) { 24 | newFlags.forEach(function(flag) { 25 | serverFlagCache[flag.key] = flag.active; 26 | flag.active = isOn(flag.key); 27 | }); 28 | angular.copy(newFlags, flags); 29 | 30 | return flags; 31 | }, 32 | 33 | updateFlagsWithPromise = function(promise) { 34 | return promise.then(function(value) { 35 | return updateFlagsAndGetAll(value.data || value); 36 | }); 37 | }, 38 | 39 | get = function() { 40 | return flags; 41 | }, 42 | 43 | set = function(newFlags) { 44 | return angular.isArray(newFlags) ? resolve(updateFlagsAndGetAll(newFlags)) : updateFlagsWithPromise(newFlags); 45 | }, 46 | 47 | enable = function(flag) { 48 | flag.active = true; 49 | featureFlagOverrides.set(flag.key, true); 50 | }, 51 | 52 | disable = function(flag) { 53 | flag.active = false; 54 | featureFlagOverrides.set(flag.key, false); 55 | }, 56 | 57 | reset = function(flag) { 58 | flag.active = serverFlagCache[flag.key]; 59 | featureFlagOverrides.remove(flag.key); 60 | }, 61 | 62 | init = function() { 63 | if (initialFlags) { 64 | set(initialFlags); 65 | } 66 | }; 67 | init(); 68 | 69 | return { 70 | set: set, 71 | get: get, 72 | enable: enable, 73 | disable: disable, 74 | reset: reset, 75 | isOn: isOn, 76 | isOnByDefault: isOnByDefault, 77 | isOverridden: isOverridden 78 | }; 79 | } 80 | 81 | angular.module('feature-flags').provider('featureFlags', function() { 82 | var initialFlags = []; 83 | 84 | this.setInitialFlags = function(flags) { 85 | initialFlags = flags; 86 | }; 87 | 88 | this.$get = function($q, featureFlagOverrides) { 89 | return new FeatureFlags($q, featureFlagOverrides, initialFlags); 90 | }; 91 | }); 92 | -------------------------------------------------------------------------------- /test/featureFlag.directive.spec.js: -------------------------------------------------------------------------------- 1 | (function(angular) { 2 | 'use strict'; 3 | 4 | var module = angular.mock.module, 5 | inject = angular.mock.inject; 6 | 7 | describe('Directive: FeatureFlag', function() { 8 | var $scope, parentElement, featureElement, flagCheck; 9 | 10 | beforeEach(module('feature-flags')); 11 | 12 | describe('when using the feature-flag directive in isolation', function() { 13 | beforeEach(inject(function($rootScope, $compile, featureFlags) { 14 | featureElement = angular.element('
Hello world
')[0]; 15 | parentElement = angular.element('
').append(featureElement)[0]; 16 | 17 | flagCheck = spyOn(featureFlags, 'isOn'); 18 | 19 | $scope = $rootScope.$new(); 20 | $compile(parentElement)($scope); 21 | })); 22 | 23 | describe('when the feature flag', function() { 24 | describe('is on', function() { 25 | beforeEach(function() { 26 | flagCheck.andReturn(true); 27 | $scope.$digest(); 28 | }); 29 | 30 | it('should leave the element in the dom', function() { 31 | expect(parentElement.innerText).toEqual('Hello world'); 32 | }); 33 | }); 34 | 35 | describe('is off', function() { 36 | beforeEach(function() { 37 | flagCheck.andReturn(false); 38 | $scope.$digest(); 39 | }); 40 | 41 | it('should swap a placeholder comment into its place', function() { 42 | expect(parentElement.childNodes.length).toBe(1); 43 | expect(parentElement.childNodes[0].nodeName).toContain('comment'); 44 | expect(parentElement.childNodes[0].textContent).toContain('FLAG_NAME is off'); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('when i toggle it on and off again', function() { 50 | beforeEach(function() { 51 | flagCheck.andReturn(true); 52 | $scope.$digest(); 53 | flagCheck.andReturn(false); 54 | $scope.$digest(); 55 | }); 56 | 57 | it('should replace the element with the placeholder comment', function() { 58 | expect(parentElement.childNodes.length).toBe(1); 59 | expect(parentElement.childNodes[0].outerHtml).toBe(featureElement.outerHtml); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('when using a dynamic feature-flag in directive', function() { 65 | var featureFlags; 66 | 67 | beforeEach(inject(function($rootScope, $compile, _featureFlags_) { 68 | featureFlags = _featureFlags_; 69 | featureElement = angular.element('
Hello world
')[0]; 70 | parentElement = angular.element('
').append(featureElement)[0]; 71 | 72 | spyOn(featureFlags, 'isOn'); 73 | 74 | $scope = $rootScope.$new(); 75 | $scope.dynamicFlags = { 76 | key: 'dynamic-flag-key' 77 | }; 78 | $compile(parentElement)($scope); 79 | })); 80 | 81 | describe('when flag is checked', function() { 82 | it('should be called with the interpolated key', function() { 83 | $scope.$digest(); 84 | expect(featureFlags.isOn).toHaveBeenCalledWith('dynamic-flag-key'); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('when using the feature-flag directive with the feature-flag-hide attribute', function() { 90 | beforeEach(inject(function($rootScope, $compile, featureFlags) { 91 | featureElement = angular.element('
Hello world
')[0]; 92 | parentElement = angular.element('
').append(featureElement)[0]; 93 | 94 | flagCheck = spyOn(featureFlags, 'isOn'); 95 | 96 | $scope = $rootScope.$new(); 97 | $compile(parentElement)($scope); 98 | })); 99 | 100 | describe('when the feature flag', function() { 101 | describe('is on', function() { 102 | beforeEach(function() { 103 | flagCheck.andReturn(true); 104 | $scope.$digest(); 105 | }); 106 | 107 | it('should swap a placeholder comment into its place', function() { 108 | expect(parentElement.childNodes.length).toBe(1); 109 | expect(parentElement.childNodes[0].nodeName).toContain('comment'); 110 | expect(parentElement.childNodes[0].textContent).toContain('FLAG_NAME is on'); 111 | }); 112 | }); 113 | 114 | describe('is off', function() { 115 | beforeEach(function() { 116 | flagCheck.andReturn(false); 117 | $scope.$digest(); 118 | }); 119 | 120 | it('should leave the element in the dom', function() { 121 | expect(parentElement.innerText).toEqual('Hello world'); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('when i toggle it on and off again', function() { 127 | beforeEach(function() { 128 | flagCheck.andReturn(true); 129 | $scope.$digest(); 130 | flagCheck.andReturn(false); 131 | $scope.$digest(); 132 | }); 133 | 134 | it('should leave the element in the dom', function() { 135 | expect(parentElement.innerText).toEqual('Hello world'); 136 | }); 137 | }); 138 | }); 139 | }); 140 | }(window.angular)); 141 | -------------------------------------------------------------------------------- /test/featureFlagOverrides.directive.spec.js: -------------------------------------------------------------------------------- 1 | (function(angular) { 2 | 'use strict'; 3 | 4 | var module = angular.mock.module, 5 | inject = angular.mock.inject; 6 | 7 | describe('Directive: featureFlagsOverrides', function() { 8 | var $scope, container, featureFlags; 9 | 10 | beforeEach(module('feature-flags')); 11 | 12 | beforeEach(inject(function($rootScope, $compile, _featureFlags_) { 13 | $scope = $rootScope.$new(); 14 | featureFlags = _featureFlags_; 15 | spyOn(featureFlags, 'isOn'); 16 | spyOn(featureFlags, 'isOverridden'); 17 | spyOn(featureFlags, 'enable'); 18 | spyOn(featureFlags, 'disable'); 19 | spyOn(featureFlags, 'reset'); 20 | spyOn(featureFlags, 'get').andReturn('FLAGS_ARRAY'); 21 | 22 | container = angular.element('
'); 23 | $compile(container)($scope); 24 | })); 25 | 26 | describe('override panel', function() { 27 | it('should get the flags', function() { 28 | expect(featureFlags.get).toHaveBeenCalled(); 29 | }); 30 | 31 | it('should set them on scope', function() { 32 | expect($scope.flags).toBe('FLAGS_ARRAY'); 33 | }); 34 | }); 35 | 36 | describe('isOn', function() { 37 | beforeEach(function() { 38 | $scope.isOn(); 39 | }); 40 | 41 | it('should check if a flag is on', function() { 42 | expect(featureFlags.isOn).toHaveBeenCalled(); 43 | }); 44 | }); 45 | 46 | describe('isOverridden', function() { 47 | beforeEach(function() { 48 | $scope.isOverridden(); 49 | }); 50 | 51 | it('should check if a flag has been overridden', function() { 52 | expect(featureFlags.isOverridden).toHaveBeenCalled(); 53 | }); 54 | }); 55 | 56 | describe('enable', function() { 57 | beforeEach(function() { 58 | $scope.enable(); 59 | }); 60 | 61 | it('should enable the flag', function() { 62 | expect(featureFlags.enable).toHaveBeenCalled(); 63 | }); 64 | }); 65 | 66 | describe('disable', function() { 67 | beforeEach(function() { 68 | $scope.disable(); 69 | }); 70 | 71 | it('should disable the flag', function() { 72 | expect(featureFlags.disable).toHaveBeenCalled(); 73 | }); 74 | }); 75 | 76 | describe('reset', function() { 77 | beforeEach(function() { 78 | $scope.reset(); 79 | }); 80 | 81 | it('should reset the flag override', function() { 82 | expect(featureFlags.reset).toHaveBeenCalled(); 83 | }); 84 | }); 85 | }); 86 | }(window.angular)); 87 | -------------------------------------------------------------------------------- /test/featureFlagOverrides.service.spec.js: -------------------------------------------------------------------------------- 1 | (function(angular) { 2 | 'use strict'; 3 | 4 | var module = angular.mock.module, 5 | inject = angular.mock.inject; 6 | 7 | describe('Service: featureFlagOverrides', function() { 8 | var service, appName = ''; 9 | 10 | beforeEach(module('feature-flags')); 11 | 12 | beforeEach(inject(function(featureFlagOverrides) { 13 | service = featureFlagOverrides; 14 | })); 15 | 16 | describe('when I set an override', function() { 17 | beforeEach(function() { 18 | spyOn(localStorage, 'setItem'); 19 | service.set('FLAG_KEY', 'VALUE'); 20 | }); 21 | 22 | it('should save the value', function() { 23 | expect(localStorage.setItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY', 'VALUE'); 24 | }); 25 | }); 26 | 27 | describe('when I set a hash of overrides', function() { 28 | beforeEach(function() { 29 | spyOn(localStorage, 'setItem'); 30 | service.set({ 31 | 'FLAG_KEY_1': 'VALUE_1', 32 | 'FLAG_KEY_2': 'VALUE_2', 33 | 'FLAG_KEY_3': 'VALUE_3' 34 | }); 35 | }); 36 | 37 | it('should save the values', function() { 38 | expect(localStorage.setItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_1', 'VALUE_1'); 39 | expect(localStorage.setItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_2', 'VALUE_2'); 40 | expect(localStorage.setItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_3', 'VALUE_3'); 41 | }); 42 | }); 43 | 44 | describe('when I get an override', function() { 45 | beforeEach(function() { 46 | spyOn(localStorage, 'getItem'); 47 | service.get('FLAG_KEY'); 48 | }); 49 | 50 | it('should get the value', function() { 51 | expect(localStorage.getItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY'); 52 | }); 53 | }); 54 | 55 | describe('when I remove an override', function() { 56 | beforeEach(function() { 57 | spyOn(localStorage, 'removeItem'); 58 | service.remove('FLAG_KEY'); 59 | }); 60 | 61 | it('should delete the value', function() { 62 | expect(localStorage.removeItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY'); 63 | }); 64 | }); 65 | 66 | describe('when I check the state of an override', function() { 67 | describe('if there is one', function() { 68 | beforeEach(function() { 69 | spyOn(localStorage, 'getItem').andReturn('true'); 70 | }); 71 | 72 | it('should return true if there is a value', function() { 73 | expect(service.isPresent('FLAG_KEY')).toBe(true); 74 | }); 75 | }); 76 | 77 | describe('if there is not one', function() { 78 | beforeEach(function() { 79 | spyOn(localStorage, 'getItem').andReturn(null); 80 | }); 81 | 82 | it('should return false if there is no value', function() { 83 | expect(service.isPresent('FLAG_KEY')).toBe(false); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('when I have a series of overrides and then clear them', function() { 89 | beforeEach(function() { 90 | spyOn(localStorage, 'removeItem'); 91 | localStorage.setItem('someOtherData', true); 92 | service.set('FLAG_KEY_1', 'VALUE'); 93 | service.set('FLAG_KEY_2', 'VALUE'); 94 | service.set('FLAG_KEY_3', 'VALUE'); 95 | service.reset(); 96 | }); 97 | 98 | afterEach(function() { 99 | localStorage.clear(); 100 | }); 101 | 102 | it('should remove all feature flags from local storage', function() { 103 | expect(localStorage.removeItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_1'); 104 | expect(localStorage.removeItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_2'); 105 | expect(localStorage.removeItem).toHaveBeenCalledWith('featureFlags.' + appName + '.' + 'FLAG_KEY_3'); 106 | }); 107 | 108 | it('should not remove unrelated local storage items', function() { 109 | expect(localStorage.removeItem).not.toHaveBeenCalledWith('someOtherData'); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('Service: featureFlagOverrides when localStorage is not available', function() { 115 | var service; 116 | 117 | beforeEach(function() { 118 | spyOn(localStorage, 'setItem').andThrow(); 119 | spyOn(localStorage, 'getItem').andThrow(); 120 | spyOn(localStorage, 'removeItem').andThrow(); 121 | }); 122 | 123 | beforeEach(module('feature-flags')); 124 | 125 | beforeEach(inject(function(featureFlagOverrides) { 126 | service = featureFlagOverrides; 127 | localStorage.setItem.reset(); 128 | })); 129 | 130 | describe('when I set an override', function() { 131 | beforeEach(function() { 132 | service.set('FLAG_KEY', 'VALUE'); 133 | }); 134 | 135 | it('do nothing', function() { 136 | expect(localStorage.setItem).not.toHaveBeenCalled(); 137 | }); 138 | }); 139 | 140 | describe('when I set a hash of overrides', function() { 141 | beforeEach(function() { 142 | service.set({ 143 | 'FLAG_KEY_1': 'VALUE_1' 144 | }); 145 | }); 146 | 147 | it('do nothing', function() { 148 | expect(localStorage.setItem).not.toHaveBeenCalled(); 149 | }); 150 | }); 151 | 152 | describe('when I get an override', function() { 153 | beforeEach(function() { 154 | service.get('FLAG_KEY'); 155 | }); 156 | 157 | it('should do nothing', function() { 158 | expect(localStorage.getItem).not.toHaveBeenCalled(); 159 | }); 160 | }); 161 | 162 | describe('when I remove an override', function() { 163 | beforeEach(function() { 164 | service.remove('FLAG_KEY'); 165 | }); 166 | 167 | it('should do nothing', function() { 168 | expect(localStorage.removeItem).not.toHaveBeenCalled(); 169 | }); 170 | }); 171 | 172 | describe('when I clear all overrides', function() { 173 | beforeEach(function() { 174 | service.reset(); 175 | }); 176 | 177 | it('should do nothing', function() { 178 | expect(localStorage.removeItem).not.toHaveBeenCalled(); 179 | }); 180 | }); 181 | 182 | describe('when I check the state of an override', function() { 183 | it('should return false', function() { 184 | expect(service.isPresent('FLAG_KEY')).toBeFalsy(); 185 | }); 186 | }); 187 | }); 188 | }(window.angular)); 189 | -------------------------------------------------------------------------------- /test/featureFlags.provider.spec.js: -------------------------------------------------------------------------------- 1 | (function(angular) { 2 | 'use strict'; 3 | 4 | var module = angular.mock.module, 5 | inject = angular.mock.inject; 6 | 7 | describe('Service: featureFlags', function() { 8 | var featureFlags, 9 | featureFlagOverrides, 10 | $rootScope, 11 | $q, 12 | $http, 13 | $httpBackend; 14 | 15 | beforeEach(module('feature-flags')); 16 | 17 | beforeEach(inject(function(_featureFlags_, _featureFlagOverrides_, _$rootScope_, _$q_, _$http_, _$httpBackend_) { 18 | featureFlags = _featureFlags_; 19 | featureFlagOverrides = _featureFlagOverrides_; 20 | $rootScope = _$rootScope_; 21 | $q = _$q_; 22 | $http = _$http_; 23 | $httpBackend = _$httpBackend_; 24 | })); 25 | 26 | describe('when I set the list of flags using an HttpPromise', function() { 27 | var flags = [{ 28 | active: true, 29 | key: 'FLAG_KEY' 30 | }, { 31 | active: false, 32 | key: 'FLAG_KEY_2' 33 | }], 34 | promise; 35 | 36 | beforeEach(function() { 37 | $httpBackend.when('GET', 'data/flags.json').respond(flags); 38 | promise = featureFlags.set($http.get('data/flags.json')); 39 | $httpBackend.flush(); 40 | }); 41 | 42 | afterEach(function() { 43 | $httpBackend.verifyNoOutstandingExpectation(); 44 | $httpBackend.verifyNoOutstandingRequest(); 45 | }); 46 | 47 | it('should return a promise that resolves to the flags', function() { 48 | promise.then(function(value) { 49 | expect(value).toEqual(flags); 50 | }); 51 | $rootScope.$digest(); 52 | }); 53 | 54 | it('should save the flags', function() { 55 | expect(featureFlags.get()).toEqual(flags); 56 | }); 57 | }); 58 | 59 | describe('when I set the list of flags using a regular promise', function() { 60 | var flags = [{ 61 | active: true, 62 | key: 'FLAG_KEY' 63 | }, { 64 | active: false, 65 | key: 'FLAG_KEY_2' 66 | }], 67 | promise; 68 | 69 | beforeEach(function() { 70 | var deferred = $q.defer(); 71 | deferred.resolve(flags); 72 | promise = featureFlags.set(deferred.promise); 73 | $rootScope.$digest(); 74 | }); 75 | 76 | it('should return a promise that resolves to the flags', function() { 77 | promise.then(function(value) { 78 | expect(value).toEqual(flags); 79 | }); 80 | $rootScope.$digest(); 81 | }); 82 | 83 | it('should save the flags', function() { 84 | expect(featureFlags.get()).toEqual(flags); 85 | }); 86 | }); 87 | 88 | describe('when I manually provide an array of flags', function() { 89 | var flags = [{ 90 | active: true, 91 | key: 'FLAG_KEY' 92 | }, { 93 | active: false, 94 | key: 'FLAG_KEY_2' 95 | }], 96 | promise; 97 | 98 | beforeEach(function() { 99 | promise = featureFlags.set(flags); 100 | }); 101 | 102 | it('should return a promise that resolves to the flags', function() { 103 | promise.then(function(value) { 104 | expect(value).toEqual(flags); 105 | }); 106 | $rootScope.$digest(); 107 | }); 108 | 109 | it('should save the flags', function() { 110 | expect(featureFlags.get()).toEqual(flags); 111 | }); 112 | }); 113 | 114 | describe('when I enable a feature flag override', function() { 115 | var flag = { 116 | active: null, 117 | key: 'FLAG_KEY' 118 | }; 119 | 120 | beforeEach(function() { 121 | spyOn(featureFlagOverrides, 'set'); 122 | featureFlags.enable(flag); 123 | }); 124 | 125 | it('should set the flag with the correct name and value', function() { 126 | expect(featureFlagOverrides.set).toHaveBeenCalledWith(flag.key, true); 127 | }); 128 | 129 | it('should set the flag as active', function() { 130 | expect(flag.active).toBe(true); 131 | }); 132 | }); 133 | 134 | describe('when I disable a feature flag override', function() { 135 | var flag = { 136 | active: null, 137 | key: 'FLAG_KEY' 138 | }; 139 | 140 | beforeEach(function() { 141 | spyOn(featureFlagOverrides, 'set'); 142 | featureFlags.disable(flag); 143 | }); 144 | 145 | it('should set the flag with the correct name and value', function() { 146 | expect(featureFlagOverrides.set).toHaveBeenCalledWith(flag.key, false); 147 | }); 148 | 149 | it('should set the flag as inactive', function() { 150 | expect(flag.active).toBe(false); 151 | }); 152 | }); 153 | 154 | describe('when I reset a feature flag to default', function() { 155 | var originalFlagValue = true, 156 | flag = { 157 | active: originalFlagValue, 158 | key: 'FLAG_KEY' 159 | }; 160 | 161 | beforeEach(function() { 162 | $httpBackend.when('GET', 'data/flags.json').respond([flag]); 163 | featureFlags.set($http.get('data/flags.json')); 164 | $httpBackend.flush(); 165 | 166 | spyOn(featureFlagOverrides, 'set'); 167 | featureFlags.disable(flag); 168 | 169 | spyOn(featureFlagOverrides, 'remove'); 170 | featureFlags.reset(flag); 171 | }); 172 | 173 | it('should remove the flag', function() { 174 | expect(featureFlagOverrides.remove).toHaveBeenCalledWith(flag.key); 175 | }); 176 | 177 | it('should reset the flag to the default value', function() { 178 | expect(flag.active).toBe(originalFlagValue); 179 | }); 180 | }); 181 | 182 | describe('when I check if there is an local override', function() { 183 | var flag = { 184 | active: null, 185 | key: 'FLAG_KEY' 186 | }; 187 | 188 | describe('if there is', function() { 189 | beforeEach(function() { 190 | spyOn(featureFlagOverrides, 'isPresent').andReturn(true); 191 | }); 192 | 193 | it('should return true when there is', function() { 194 | expect(featureFlags.isOverridden(flag.key)).toBe(true); 195 | }); 196 | }); 197 | 198 | describe('if there is not', function() { 199 | beforeEach(function() { 200 | spyOn(featureFlagOverrides, 'isPresent').andReturn(false); 201 | }); 202 | 203 | it('should return true when there is', function() { 204 | expect(featureFlags.isOverridden(flag.key)).toBe(false); 205 | }); 206 | }); 207 | }); 208 | 209 | describe('when I check a feature flag default state', function() { 210 | var onFlag = { 211 | active: true, 212 | key: 'FLAG_KEY_ON' 213 | }; 214 | var offFlag = { 215 | active: false, 216 | key: 'FLAG_KEY_OFF' 217 | }; 218 | var onFlagOverridden = { 219 | active: true, 220 | key: 'FLAG_KEY_ON_OVERRIDDEN' 221 | }; 222 | var offFlagOverridden = { 223 | active: false, 224 | key: 'FLAG_KEY_OFF_OVERRRIDDEN' 225 | }; 226 | var undefinedFlag = { 227 | key: 'FLAG_UNDEFINED' 228 | }; 229 | var undefinedFlagOverridden = { 230 | key: 'FLAG_UNDEFINED_OVERRIDDEN' 231 | }; 232 | 233 | beforeEach(function(done) { 234 | var flagsToLoad = [onFlag, offFlag, onFlagOverridden, offFlagOverridden]; 235 | $httpBackend.when('GET', 'data/flags.json').respond(flagsToLoad); 236 | featureFlags.set($http.get('data/flags.json')).finally(done); 237 | $httpBackend.flush(); 238 | }); 239 | 240 | beforeEach(function() { 241 | featureFlags.disable(onFlagOverridden.key); 242 | featureFlags.enable(offFlagOverridden.key); 243 | featureFlags.enable(undefinedFlagOverridden.key); 244 | }); 245 | 246 | afterEach(function() { 247 | $httpBackend.verifyNoOutstandingExpectation(); 248 | $httpBackend.verifyNoOutstandingRequest(); 249 | }); 250 | 251 | it('should report feature is on by default when it is', function() { 252 | expect(featureFlags.isOnByDefault(onFlag.key)).toBe(true); 253 | }); 254 | 255 | it('should report feature is off by default when it is', function() { 256 | expect(featureFlags.isOnByDefault(offFlag.key)).toBe(false); 257 | }); 258 | 259 | it('should return undefined if the key was not loaded by set()', function() { 260 | expect(typeof featureFlags.isOnByDefault(undefinedFlag.key)).toBe('undefined'); 261 | }); 262 | 263 | it('should report feature is on by default when it is even when disabled', function() { 264 | expect(featureFlags.isOnByDefault(onFlagOverridden.key)).toBe(true); 265 | }); 266 | 267 | it('should report feature is off by default when it is even when enabled', function() { 268 | expect(featureFlags.isOnByDefault(offFlagOverridden.key)).toBe(false); 269 | }); 270 | 271 | it('should return undefined if the key was not loaded by set() even when enabled', function() { 272 | expect(typeof featureFlags.isOnByDefault(undefinedFlagOverridden.key)).toBe('undefined'); 273 | }); 274 | }); 275 | 276 | describe('when I check a feature flags state', function() { 277 | describe('if the feature is disabled on the server', function() { 278 | var flag = { 279 | active: false, 280 | key: 'FLAG_KEY' 281 | }; 282 | 283 | beforeEach(function() { 284 | $httpBackend.when('GET', 'data/flags.json').respond([flag]); 285 | featureFlags.set($http.get('data/flags.json')); 286 | $httpBackend.flush(); 287 | }); 288 | 289 | afterEach(function() { 290 | $httpBackend.verifyNoOutstandingExpectation(); 291 | $httpBackend.verifyNoOutstandingRequest(); 292 | }); 293 | 294 | describe('and there is a local override to turn it on', function() { 295 | beforeEach(function() { 296 | spyOn(featureFlagOverrides, 'isPresent').andReturn(true); 297 | spyOn(featureFlagOverrides, 'get').andReturn('true'); 298 | }); 299 | 300 | it('should report the feature as being on', function() { 301 | expect(featureFlags.isOn(flag.key)).toBe(true); 302 | }); 303 | }); 304 | 305 | describe('and there is no local override to turn it on', function() { 306 | beforeEach(function() { 307 | spyOn(featureFlagOverrides, 'isPresent').andReturn(false); 308 | }); 309 | 310 | it('should report the feature as being off', function() { 311 | expect(featureFlags.isOn(flag.key)).toBe(flag.active); 312 | }); 313 | }); 314 | }); 315 | 316 | describe('if the feature is enabled on the server', function() { 317 | var flag = { 318 | active: true, 319 | key: 'FLAG_KEY' 320 | }; 321 | 322 | beforeEach(function() { 323 | $httpBackend.when('GET', 'data/flags.json').respond([flag]); 324 | featureFlags.set($http.get('data/flags.json')); 325 | $httpBackend.flush(); 326 | }); 327 | 328 | afterEach(function() { 329 | $httpBackend.verifyNoOutstandingExpectation(); 330 | $httpBackend.verifyNoOutstandingRequest(); 331 | }); 332 | 333 | describe('and there is a local override to turn it off', function() { 334 | beforeEach(function() { 335 | spyOn(featureFlagOverrides, 'isPresent').andReturn(true); 336 | spyOn(featureFlagOverrides, 'get').andReturn('false'); 337 | }); 338 | 339 | it('should report the feature as being off', function() { 340 | expect(featureFlags.isOn(flag.key)).toBe(false); 341 | }); 342 | }); 343 | 344 | describe('and there is no local override to turn it off', function() { 345 | beforeEach(function() { 346 | spyOn(featureFlagOverrides, 'isPresent').andReturn(false); 347 | }); 348 | 349 | it('should report the feature as being on', function() { 350 | expect(featureFlags.isOn(flag.key)).toBe(true); 351 | }); 352 | }); 353 | }); 354 | }); 355 | }); 356 | 357 | describe('Provider: featureFlags', function() { 358 | var featureFlags, 359 | flags = [{ 360 | active: true, 361 | key: 'FLAG_KEY' 362 | }, { 363 | active: false, 364 | key: 'FLAG_KEY_2' 365 | }]; 366 | 367 | describe('When no flags are set in the config phase', function() { 368 | beforeEach(module('feature-flags', function(featureFlagsProvider) { 369 | featureFlagsProvider.setInitialFlags(null); 370 | })); 371 | 372 | beforeEach(inject(function(_featureFlags_) { 373 | featureFlags = _featureFlags_; 374 | })); 375 | 376 | it('should return an empty array for current feature flags', function() { 377 | expect(featureFlags.get()).toEqual([]); 378 | }); 379 | }); 380 | 381 | describe('When flags are set in the config phase', function() { 382 | beforeEach(module('feature-flags', function(featureFlagsProvider) { 383 | featureFlagsProvider.setInitialFlags(flags); 384 | })); 385 | 386 | beforeEach(inject(function(_featureFlags_) { 387 | featureFlags = _featureFlags_; 388 | })); 389 | 390 | it('should init the flags with the ones set in the config phase', function() { 391 | expect(featureFlags.get()).toEqual(flags); 392 | }); 393 | }); 394 | }); 395 | }(window.angular)); 396 | -------------------------------------------------------------------------------- /test/vendor/angular-mocks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.1.5 3 | * (c) 2010-2012 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | * 6 | * TODO(vojta): wrap whole file into closure during build 7 | */ 8 | 9 | /** 10 | * @ngdoc overview 11 | * @name angular.mock 12 | * @description 13 | * 14 | * Namespace from 'angular-mocks.js' which contains testing related code. 15 | */ 16 | angular.mock = {}; 17 | 18 | /** 19 | * ! This is a private undocumented service ! 20 | * 21 | * @name ngMock.$browser 22 | * 23 | * @description 24 | * This service is a mock implementation of {@link ng.$browser}. It provides fake 25 | * implementation for commonly used browser apis that are hard to test, e.g. setTimeout, xhr, 26 | * cookies, etc... 27 | * 28 | * The api of this service is the same as that of the real {@link ng.$browser $browser}, except 29 | * that there are several helper methods available which can be used in tests. 30 | */ 31 | angular.mock.$BrowserProvider = function() { 32 | this.$get = function(){ 33 | return new angular.mock.$Browser(); 34 | }; 35 | }; 36 | 37 | angular.mock.$Browser = function() { 38 | var self = this; 39 | 40 | this.isMock = true; 41 | self.$$url = "http://server/"; 42 | self.$$lastUrl = self.$$url; // used by url polling fn 43 | self.pollFns = []; 44 | 45 | // TODO(vojta): remove this temporary api 46 | self.$$completeOutstandingRequest = angular.noop; 47 | self.$$incOutstandingRequestCount = angular.noop; 48 | 49 | 50 | // register url polling fn 51 | 52 | self.onUrlChange = function(listener) { 53 | self.pollFns.push( 54 | function() { 55 | if (self.$$lastUrl != self.$$url) { 56 | self.$$lastUrl = self.$$url; 57 | listener(self.$$url); 58 | } 59 | } 60 | ); 61 | 62 | return listener; 63 | }; 64 | 65 | self.cookieHash = {}; 66 | self.lastCookieHash = {}; 67 | self.deferredFns = []; 68 | self.deferredNextId = 0; 69 | 70 | self.defer = function(fn, delay) { 71 | delay = delay || 0; 72 | self.deferredFns.push({time:(self.defer.now + delay), fn:fn, id: self.deferredNextId}); 73 | self.deferredFns.sort(function(a,b){ return a.time - b.time;}); 74 | return self.deferredNextId++; 75 | }; 76 | 77 | 78 | self.defer.now = 0; 79 | 80 | 81 | self.defer.cancel = function(deferId) { 82 | var fnIndex; 83 | 84 | angular.forEach(self.deferredFns, function(fn, index) { 85 | if (fn.id === deferId) fnIndex = index; 86 | }); 87 | 88 | if (fnIndex !== undefined) { 89 | self.deferredFns.splice(fnIndex, 1); 90 | return true; 91 | } 92 | 93 | return false; 94 | }; 95 | 96 | 97 | /** 98 | * @name ngMock.$browser#defer.flush 99 | * @methodOf ngMock.$browser 100 | * 101 | * @description 102 | * Flushes all pending requests and executes the defer callbacks. 103 | * 104 | * @param {number=} number of milliseconds to flush. See {@link #defer.now} 105 | */ 106 | self.defer.flush = function(delay) { 107 | if (angular.isDefined(delay)) { 108 | self.defer.now += delay; 109 | } else { 110 | if (self.deferredFns.length) { 111 | self.defer.now = self.deferredFns[self.deferredFns.length-1].time; 112 | } else { 113 | throw Error('No deferred tasks to be flushed'); 114 | } 115 | } 116 | 117 | while (self.deferredFns.length && self.deferredFns[0].time <= self.defer.now) { 118 | self.deferredFns.shift().fn(); 119 | } 120 | }; 121 | /** 122 | * @name ngMock.$browser#defer.now 123 | * @propertyOf ngMock.$browser 124 | * 125 | * @description 126 | * Current milliseconds mock time. 127 | */ 128 | 129 | self.$$baseHref = ''; 130 | self.baseHref = function() { 131 | return this.$$baseHref; 132 | }; 133 | }; 134 | angular.mock.$Browser.prototype = { 135 | 136 | /** 137 | * @name ngMock.$browser#poll 138 | * @methodOf ngMock.$browser 139 | * 140 | * @description 141 | * run all fns in pollFns 142 | */ 143 | poll: function poll() { 144 | angular.forEach(this.pollFns, function(pollFn){ 145 | pollFn(); 146 | }); 147 | }, 148 | 149 | addPollFn: function(pollFn) { 150 | this.pollFns.push(pollFn); 151 | return pollFn; 152 | }, 153 | 154 | url: function(url, replace) { 155 | if (url) { 156 | this.$$url = url; 157 | return this; 158 | } 159 | 160 | return this.$$url; 161 | }, 162 | 163 | cookies: function(name, value) { 164 | if (name) { 165 | if (value == undefined) { 166 | delete this.cookieHash[name]; 167 | } else { 168 | if (angular.isString(value) && //strings only 169 | value.length <= 4096) { //strict cookie storage limits 170 | this.cookieHash[name] = value; 171 | } 172 | } 173 | } else { 174 | if (!angular.equals(this.cookieHash, this.lastCookieHash)) { 175 | this.lastCookieHash = angular.copy(this.cookieHash); 176 | this.cookieHash = angular.copy(this.cookieHash); 177 | } 178 | return this.cookieHash; 179 | } 180 | }, 181 | 182 | notifyWhenNoOutstandingRequests: function(fn) { 183 | fn(); 184 | } 185 | }; 186 | 187 | 188 | /** 189 | * @ngdoc object 190 | * @name ngMock.$exceptionHandlerProvider 191 | * 192 | * @description 193 | * Configures the mock implementation of {@link ng.$exceptionHandler} to rethrow or to log errors passed 194 | * into the `$exceptionHandler`. 195 | */ 196 | 197 | /** 198 | * @ngdoc object 199 | * @name ngMock.$exceptionHandler 200 | * 201 | * @description 202 | * Mock implementation of {@link ng.$exceptionHandler} that rethrows or logs errors passed 203 | * into it. See {@link ngMock.$exceptionHandlerProvider $exceptionHandlerProvider} for configuration 204 | * information. 205 | * 206 | * 207 | *
 208 |  *   describe('$exceptionHandlerProvider', function() {
 209 |  *
 210 |  *     it('should capture log messages and exceptions', function() {
 211 |  *
 212 |  *       module(function($exceptionHandlerProvider) {
 213 |  *         $exceptionHandlerProvider.mode('log');
 214 |  *       });
 215 |  *
 216 |  *       inject(function($log, $exceptionHandler, $timeout) {
 217 |  *         $timeout(function() { $log.log(1); });
 218 |  *         $timeout(function() { $log.log(2); throw 'banana peel'; });
 219 |  *         $timeout(function() { $log.log(3); });
 220 |  *         expect($exceptionHandler.errors).toEqual([]);
 221 |  *         expect($log.assertEmpty());
 222 |  *         $timeout.flush();
 223 |  *         expect($exceptionHandler.errors).toEqual(['banana peel']);
 224 |  *         expect($log.log.logs).toEqual([[1], [2], [3]]);
 225 |  *       });
 226 |  *     });
 227 |  *   });
 228 |  * 
229 | */ 230 | 231 | angular.mock.$ExceptionHandlerProvider = function() { 232 | var handler; 233 | 234 | /** 235 | * @ngdoc method 236 | * @name ngMock.$exceptionHandlerProvider#mode 237 | * @methodOf ngMock.$exceptionHandlerProvider 238 | * 239 | * @description 240 | * Sets the logging mode. 241 | * 242 | * @param {string} mode Mode of operation, defaults to `rethrow`. 243 | * 244 | * - `rethrow`: If any errors are passed into the handler in tests, it typically 245 | * means that there is a bug in the application or test, so this mock will 246 | * make these tests fail. 247 | * - `log`: Sometimes it is desirable to test that an error is thrown, for this case the `log` mode stores an 248 | * array of errors in `$exceptionHandler.errors`, to allow later assertion of them. 249 | * See {@link ngMock.$log#assertEmpty assertEmpty()} and 250 | * {@link ngMock.$log#reset reset()} 251 | */ 252 | this.mode = function(mode) { 253 | switch(mode) { 254 | case 'rethrow': 255 | handler = function(e) { 256 | throw e; 257 | }; 258 | break; 259 | case 'log': 260 | var errors = []; 261 | 262 | handler = function(e) { 263 | if (arguments.length == 1) { 264 | errors.push(e); 265 | } else { 266 | errors.push([].slice.call(arguments, 0)); 267 | } 268 | }; 269 | 270 | handler.errors = errors; 271 | break; 272 | default: 273 | throw Error("Unknown mode '" + mode + "', only 'log'/'rethrow' modes are allowed!"); 274 | } 275 | }; 276 | 277 | this.$get = function() { 278 | return handler; 279 | }; 280 | 281 | this.mode('rethrow'); 282 | }; 283 | 284 | 285 | /** 286 | * @ngdoc service 287 | * @name ngMock.$log 288 | * 289 | * @description 290 | * Mock implementation of {@link ng.$log} that gathers all logged messages in arrays 291 | * (one array per logging level). These arrays are exposed as `logs` property of each of the 292 | * level-specific log function, e.g. for level `error` the array is exposed as `$log.error.logs`. 293 | * 294 | */ 295 | angular.mock.$LogProvider = function() { 296 | 297 | function concat(array1, array2, index) { 298 | return array1.concat(Array.prototype.slice.call(array2, index)); 299 | } 300 | 301 | 302 | this.$get = function () { 303 | var $log = { 304 | log: function() { $log.log.logs.push(concat([], arguments, 0)); }, 305 | warn: function() { $log.warn.logs.push(concat([], arguments, 0)); }, 306 | info: function() { $log.info.logs.push(concat([], arguments, 0)); }, 307 | error: function() { $log.error.logs.push(concat([], arguments, 0)); } 308 | }; 309 | 310 | /** 311 | * @ngdoc method 312 | * @name ngMock.$log#reset 313 | * @methodOf ngMock.$log 314 | * 315 | * @description 316 | * Reset all of the logging arrays to empty. 317 | */ 318 | $log.reset = function () { 319 | /** 320 | * @ngdoc property 321 | * @name ngMock.$log#log.logs 322 | * @propertyOf ngMock.$log 323 | * 324 | * @description 325 | * Array of messages logged using {@link ngMock.$log#log}. 326 | * 327 | * @example 328 | *
 329 |        * $log.log('Some Log');
 330 |        * var first = $log.log.logs.unshift();
 331 |        * 
332 | */ 333 | $log.log.logs = []; 334 | /** 335 | * @ngdoc property 336 | * @name ngMock.$log#warn.logs 337 | * @propertyOf ngMock.$log 338 | * 339 | * @description 340 | * Array of messages logged using {@link ngMock.$log#warn}. 341 | * 342 | * @example 343 | *
 344 |        * $log.warn('Some Warning');
 345 |        * var first = $log.warn.logs.unshift();
 346 |        * 
347 | */ 348 | $log.warn.logs = []; 349 | /** 350 | * @ngdoc property 351 | * @name ngMock.$log#info.logs 352 | * @propertyOf ngMock.$log 353 | * 354 | * @description 355 | * Array of messages logged using {@link ngMock.$log#info}. 356 | * 357 | * @example 358 | *
 359 |        * $log.info('Some Info');
 360 |        * var first = $log.info.logs.unshift();
 361 |        * 
362 | */ 363 | $log.info.logs = []; 364 | /** 365 | * @ngdoc property 366 | * @name ngMock.$log#error.logs 367 | * @propertyOf ngMock.$log 368 | * 369 | * @description 370 | * Array of messages logged using {@link ngMock.$log#error}. 371 | * 372 | * @example 373 | *
 374 |        * $log.log('Some Error');
 375 |        * var first = $log.error.logs.unshift();
 376 |        * 
377 | */ 378 | $log.error.logs = []; 379 | }; 380 | 381 | /** 382 | * @ngdoc method 383 | * @name ngMock.$log#assertEmpty 384 | * @methodOf ngMock.$log 385 | * 386 | * @description 387 | * Assert that the all of the logging methods have no logged messages. If messages present, an exception is thrown. 388 | */ 389 | $log.assertEmpty = function() { 390 | var errors = []; 391 | angular.forEach(['error', 'warn', 'info', 'log'], function(logLevel) { 392 | angular.forEach($log[logLevel].logs, function(log) { 393 | angular.forEach(log, function (logItem) { 394 | errors.push('MOCK $log (' + logLevel + '): ' + String(logItem) + '\n' + (logItem.stack || '')); 395 | }); 396 | }); 397 | }); 398 | if (errors.length) { 399 | errors.unshift("Expected $log to be empty! Either a message was logged unexpectedly, or an expected " + 400 | "log message was not checked and removed:"); 401 | errors.push(''); 402 | throw new Error(errors.join('\n---------\n')); 403 | } 404 | }; 405 | 406 | $log.reset(); 407 | return $log; 408 | }; 409 | }; 410 | 411 | 412 | (function() { 413 | var R_ISO8061_STR = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?:\:?(\d\d)(?:\:?(\d\d)(?:\.(\d{3}))?)?)?(Z|([+-])(\d\d):?(\d\d)))?$/; 414 | 415 | function jsonStringToDate(string){ 416 | var match; 417 | if (match = string.match(R_ISO8061_STR)) { 418 | var date = new Date(0), 419 | tzHour = 0, 420 | tzMin = 0; 421 | if (match[9]) { 422 | tzHour = int(match[9] + match[10]); 423 | tzMin = int(match[9] + match[11]); 424 | } 425 | date.setUTCFullYear(int(match[1]), int(match[2]) - 1, int(match[3])); 426 | date.setUTCHours(int(match[4]||0) - tzHour, int(match[5]||0) - tzMin, int(match[6]||0), int(match[7]||0)); 427 | return date; 428 | } 429 | return string; 430 | } 431 | 432 | function int(str) { 433 | return parseInt(str, 10); 434 | } 435 | 436 | function padNumber(num, digits, trim) { 437 | var neg = ''; 438 | if (num < 0) { 439 | neg = '-'; 440 | num = -num; 441 | } 442 | num = '' + num; 443 | while(num.length < digits) num = '0' + num; 444 | if (trim) 445 | num = num.substr(num.length - digits); 446 | return neg + num; 447 | } 448 | 449 | 450 | /** 451 | * @ngdoc object 452 | * @name angular.mock.TzDate 453 | * @description 454 | * 455 | * *NOTE*: this is not an injectable instance, just a globally available mock class of `Date`. 456 | * 457 | * Mock of the Date type which has its timezone specified via constructor arg. 458 | * 459 | * The main purpose is to create Date-like instances with timezone fixed to the specified timezone 460 | * offset, so that we can test code that depends on local timezone settings without dependency on 461 | * the time zone settings of the machine where the code is running. 462 | * 463 | * @param {number} offset Offset of the *desired* timezone in hours (fractions will be honored) 464 | * @param {(number|string)} timestamp Timestamp representing the desired time in *UTC* 465 | * 466 | * @example 467 | * !!!! WARNING !!!!! 468 | * This is not a complete Date object so only methods that were implemented can be called safely. 469 | * To make matters worse, TzDate instances inherit stuff from Date via a prototype. 470 | * 471 | * We do our best to intercept calls to "unimplemented" methods, but since the list of methods is 472 | * incomplete we might be missing some non-standard methods. This can result in errors like: 473 | * "Date.prototype.foo called on incompatible Object". 474 | * 475 | *
 476 |    * var newYearInBratislava = new TzDate(-1, '2009-12-31T23:00:00Z');
 477 |    * newYearInBratislava.getTimezoneOffset() => -60;
 478 |    * newYearInBratislava.getFullYear() => 2010;
 479 |    * newYearInBratislava.getMonth() => 0;
 480 |    * newYearInBratislava.getDate() => 1;
 481 |    * newYearInBratislava.getHours() => 0;
 482 |    * newYearInBratislava.getMinutes() => 0;
 483 |    * newYearInBratislava.getSeconds() => 0;
 484 |    * 
485 | * 486 | */ 487 | angular.mock.TzDate = function (offset, timestamp) { 488 | var self = new Date(0); 489 | if (angular.isString(timestamp)) { 490 | var tsStr = timestamp; 491 | 492 | self.origDate = jsonStringToDate(timestamp); 493 | 494 | timestamp = self.origDate.getTime(); 495 | if (isNaN(timestamp)) 496 | throw { 497 | name: "Illegal Argument", 498 | message: "Arg '" + tsStr + "' passed into TzDate constructor is not a valid date string" 499 | }; 500 | } else { 501 | self.origDate = new Date(timestamp); 502 | } 503 | 504 | var localOffset = new Date(timestamp).getTimezoneOffset(); 505 | self.offsetDiff = localOffset*60*1000 - offset*1000*60*60; 506 | self.date = new Date(timestamp + self.offsetDiff); 507 | 508 | self.getTime = function() { 509 | return self.date.getTime() - self.offsetDiff; 510 | }; 511 | 512 | self.toLocaleDateString = function() { 513 | return self.date.toLocaleDateString(); 514 | }; 515 | 516 | self.getFullYear = function() { 517 | return self.date.getFullYear(); 518 | }; 519 | 520 | self.getMonth = function() { 521 | return self.date.getMonth(); 522 | }; 523 | 524 | self.getDate = function() { 525 | return self.date.getDate(); 526 | }; 527 | 528 | self.getHours = function() { 529 | return self.date.getHours(); 530 | }; 531 | 532 | self.getMinutes = function() { 533 | return self.date.getMinutes(); 534 | }; 535 | 536 | self.getSeconds = function() { 537 | return self.date.getSeconds(); 538 | }; 539 | 540 | self.getMilliseconds = function() { 541 | return self.date.getMilliseconds(); 542 | }; 543 | 544 | self.getTimezoneOffset = function() { 545 | return offset * 60; 546 | }; 547 | 548 | self.getUTCFullYear = function() { 549 | return self.origDate.getUTCFullYear(); 550 | }; 551 | 552 | self.getUTCMonth = function() { 553 | return self.origDate.getUTCMonth(); 554 | }; 555 | 556 | self.getUTCDate = function() { 557 | return self.origDate.getUTCDate(); 558 | }; 559 | 560 | self.getUTCHours = function() { 561 | return self.origDate.getUTCHours(); 562 | }; 563 | 564 | self.getUTCMinutes = function() { 565 | return self.origDate.getUTCMinutes(); 566 | }; 567 | 568 | self.getUTCSeconds = function() { 569 | return self.origDate.getUTCSeconds(); 570 | }; 571 | 572 | self.getUTCMilliseconds = function() { 573 | return self.origDate.getUTCMilliseconds(); 574 | }; 575 | 576 | self.getDay = function() { 577 | return self.date.getDay(); 578 | }; 579 | 580 | // provide this method only on browsers that already have it 581 | if (self.toISOString) { 582 | self.toISOString = function() { 583 | return padNumber(self.origDate.getUTCFullYear(), 4) + '-' + 584 | padNumber(self.origDate.getUTCMonth() + 1, 2) + '-' + 585 | padNumber(self.origDate.getUTCDate(), 2) + 'T' + 586 | padNumber(self.origDate.getUTCHours(), 2) + ':' + 587 | padNumber(self.origDate.getUTCMinutes(), 2) + ':' + 588 | padNumber(self.origDate.getUTCSeconds(), 2) + '.' + 589 | padNumber(self.origDate.getUTCMilliseconds(), 3) + 'Z' 590 | } 591 | } 592 | 593 | //hide all methods not implemented in this mock that the Date prototype exposes 594 | var unimplementedMethods = ['getUTCDay', 595 | 'getYear', 'setDate', 'setFullYear', 'setHours', 'setMilliseconds', 596 | 'setMinutes', 'setMonth', 'setSeconds', 'setTime', 'setUTCDate', 'setUTCFullYear', 597 | 'setUTCHours', 'setUTCMilliseconds', 'setUTCMinutes', 'setUTCMonth', 'setUTCSeconds', 598 | 'setYear', 'toDateString', 'toGMTString', 'toJSON', 'toLocaleFormat', 'toLocaleString', 599 | 'toLocaleTimeString', 'toSource', 'toString', 'toTimeString', 'toUTCString', 'valueOf']; 600 | 601 | angular.forEach(unimplementedMethods, function(methodName) { 602 | self[methodName] = function() { 603 | throw Error("Method '" + methodName + "' is not implemented in the TzDate mock"); 604 | }; 605 | }); 606 | 607 | return self; 608 | }; 609 | 610 | //make "tzDateInstance instanceof Date" return true 611 | angular.mock.TzDate.prototype = Date.prototype; 612 | })(); 613 | 614 | /** 615 | * @ngdoc function 616 | * @name angular.mock.createMockWindow 617 | * @description 618 | * 619 | * This function creates a mock window object useful for controlling access ot setTimeout, but mocking out 620 | * sufficient window's properties to allow Angular to execute. 621 | * 622 | * @example 623 | * 624 | *
 625 |     beforeEach(module(function($provide) {
 626 |       $provide.value('$window', window = angular.mock.createMockWindow());
 627 |     }));
 628 | 
 629 |     it('should do something', inject(function($window) {
 630 |       var val = null;
 631 |       $window.setTimeout(function() { val = 123; }, 10);
 632 |       expect(val).toEqual(null);
 633 |       window.setTimeout.expect(10).process();
 634 |       expect(val).toEqual(123);
 635 |     });
 636 |  * 
637 | * 638 | */ 639 | angular.mock.createMockWindow = function() { 640 | var mockWindow = {}; 641 | var setTimeoutQueue = []; 642 | 643 | mockWindow.document = window.document; 644 | mockWindow.getComputedStyle = angular.bind(window, window.getComputedStyle); 645 | mockWindow.scrollTo = angular.bind(window, window.scrollTo); 646 | mockWindow.navigator = window.navigator; 647 | mockWindow.setTimeout = function(fn, delay) { 648 | setTimeoutQueue.push({fn: fn, delay: delay}); 649 | }; 650 | mockWindow.setTimeout.queue = setTimeoutQueue; 651 | mockWindow.setTimeout.expect = function(delay) { 652 | if (setTimeoutQueue.length > 0) { 653 | return { 654 | process: function() { 655 | var tick = setTimeoutQueue.shift(); 656 | expect(tick.delay).toEqual(delay); 657 | tick.fn(); 658 | } 659 | }; 660 | } else { 661 | expect('SetTimoutQueue empty. Expecting delay of ').toEqual(delay); 662 | } 663 | }; 664 | 665 | return mockWindow; 666 | }; 667 | 668 | /** 669 | * @ngdoc function 670 | * @name angular.mock.dump 671 | * @description 672 | * 673 | * *NOTE*: this is not an injectable instance, just a globally available function. 674 | * 675 | * Method for serializing common angular objects (scope, elements, etc..) into strings, useful for debugging. 676 | * 677 | * This method is also available on window, where it can be used to display objects on debug console. 678 | * 679 | * @param {*} object - any object to turn into string. 680 | * @return {string} a serialized string of the argument 681 | */ 682 | angular.mock.dump = function(object) { 683 | return serialize(object); 684 | 685 | function serialize(object) { 686 | var out; 687 | 688 | if (angular.isElement(object)) { 689 | object = angular.element(object); 690 | out = angular.element('
'); 691 | angular.forEach(object, function(element) { 692 | out.append(angular.element(element).clone()); 693 | }); 694 | out = out.html(); 695 | } else if (angular.isArray(object)) { 696 | out = []; 697 | angular.forEach(object, function(o) { 698 | out.push(serialize(o)); 699 | }); 700 | out = '[ ' + out.join(', ') + ' ]'; 701 | } else if (angular.isObject(object)) { 702 | if (angular.isFunction(object.$eval) && angular.isFunction(object.$apply)) { 703 | out = serializeScope(object); 704 | } else if (object instanceof Error) { 705 | out = object.stack || ('' + object.name + ': ' + object.message); 706 | } else { 707 | out = angular.toJson(object, true); 708 | } 709 | } else { 710 | out = String(object); 711 | } 712 | 713 | return out; 714 | } 715 | 716 | function serializeScope(scope, offset) { 717 | offset = offset || ' '; 718 | var log = [offset + 'Scope(' + scope.$id + '): {']; 719 | for ( var key in scope ) { 720 | if (scope.hasOwnProperty(key) && !key.match(/^(\$|this)/)) { 721 | log.push(' ' + key + ': ' + angular.toJson(scope[key])); 722 | } 723 | } 724 | var child = scope.$$childHead; 725 | while(child) { 726 | log.push(serializeScope(child, offset + ' ')); 727 | child = child.$$nextSibling; 728 | } 729 | log.push('}'); 730 | return log.join('\n' + offset); 731 | } 732 | }; 733 | 734 | /** 735 | * @ngdoc object 736 | * @name ngMock.$httpBackend 737 | * @description 738 | * Fake HTTP backend implementation suitable for unit testing applications that use the 739 | * {@link ng.$http $http service}. 740 | * 741 | * *Note*: For fake HTTP backend implementation suitable for end-to-end testing or backend-less 742 | * development please see {@link ngMockE2E.$httpBackend e2e $httpBackend mock}. 743 | * 744 | * During unit testing, we want our unit tests to run quickly and have no external dependencies so 745 | * we don’t want to send {@link https://developer.mozilla.org/en/xmlhttprequest XHR} or 746 | * {@link http://en.wikipedia.org/wiki/JSONP JSONP} requests to a real server. All we really need is 747 | * to verify whether a certain request has been sent or not, or alternatively just let the 748 | * application make requests, respond with pre-trained responses and assert that the end result is 749 | * what we expect it to be. 750 | * 751 | * This mock implementation can be used to respond with static or dynamic responses via the 752 | * `expect` and `when` apis and their shortcuts (`expectGET`, `whenPOST`, etc). 753 | * 754 | * When an Angular application needs some data from a server, it calls the $http service, which 755 | * sends the request to a real server using $httpBackend service. With dependency injection, it is 756 | * easy to inject $httpBackend mock (which has the same API as $httpBackend) and use it to verify 757 | * the requests and respond with some testing data without sending a request to real server. 758 | * 759 | * There are two ways to specify what test data should be returned as http responses by the mock 760 | * backend when the code under test makes http requests: 761 | * 762 | * - `$httpBackend.expect` - specifies a request expectation 763 | * - `$httpBackend.when` - specifies a backend definition 764 | * 765 | * 766 | * # Request Expectations vs Backend Definitions 767 | * 768 | * Request expectations provide a way to make assertions about requests made by the application and 769 | * to define responses for those requests. The test will fail if the expected requests are not made 770 | * or they are made in the wrong order. 771 | * 772 | * Backend definitions allow you to define a fake backend for your application which doesn't assert 773 | * if a particular request was made or not, it just returns a trained response if a request is made. 774 | * The test will pass whether or not the request gets made during testing. 775 | * 776 | * 777 | * 778 | * 779 | * 780 | * 781 | * 782 | * 783 | * 784 | * 785 | * 786 | * 787 | * 788 | * 789 | * 790 | * 791 | * 792 | * 793 | * 794 | * 795 | * 796 | * 797 | * 798 | * 799 | * 800 | * 801 | * 802 | * 803 | * 804 | * 805 | * 806 | * 807 | * 808 | * 809 | *
Request expectationsBackend definitions
Syntax.expect(...).respond(...).when(...).respond(...)
Typical usagestrict unit testsloose (black-box) unit testing
Fulfills multiple requestsNOYES
Order of requests mattersYESNO
Request requiredYESNO
Response requiredoptional (see below)YES
810 | * 811 | * In cases where both backend definitions and request expectations are specified during unit 812 | * testing, the request expectations are evaluated first. 813 | * 814 | * If a request expectation has no response specified, the algorithm will search your backend 815 | * definitions for an appropriate response. 816 | * 817 | * If a request didn't match any expectation or if the expectation doesn't have the response 818 | * defined, the backend definitions are evaluated in sequential order to see if any of them match 819 | * the request. The response from the first matched definition is returned. 820 | * 821 | * 822 | * # Flushing HTTP requests 823 | * 824 | * The $httpBackend used in production, always responds to requests with responses asynchronously. 825 | * If we preserved this behavior in unit testing, we'd have to create async unit tests, which are 826 | * hard to write, follow and maintain. At the same time the testing mock, can't respond 827 | * synchronously because that would change the execution of the code under test. For this reason the 828 | * mock $httpBackend has a `flush()` method, which allows the test to explicitly flush pending 829 | * requests and thus preserving the async api of the backend, while allowing the test to execute 830 | * synchronously. 831 | * 832 | * 833 | * # Unit testing with mock $httpBackend 834 | * 835 | *
 836 |    // controller
 837 |    function MyController($scope, $http) {
 838 |      $http.get('/auth.py').success(function(data) {
 839 |        $scope.user = data;
 840 |      });
 841 | 
 842 |      this.saveMessage = function(message) {
 843 |        $scope.status = 'Saving...';
 844 |        $http.post('/add-msg.py', message).success(function(response) {
 845 |          $scope.status = '';
 846 |        }).error(function() {
 847 |          $scope.status = 'ERROR!';
 848 |        });
 849 |      };
 850 |    }
 851 | 
 852 |    // testing controller
 853 |    var $httpBackend;
 854 | 
 855 |    beforeEach(inject(function($injector) {
 856 |      $httpBackend = $injector.get('$httpBackend');
 857 | 
 858 |      // backend definition common for all tests
 859 |      $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'});
 860 |    }));
 861 | 
 862 | 
 863 |    afterEach(function() {
 864 |      $httpBackend.verifyNoOutstandingExpectation();
 865 |      $httpBackend.verifyNoOutstandingRequest();
 866 |    });
 867 | 
 868 | 
 869 |    it('should fetch authentication token', function() {
 870 |      $httpBackend.expectGET('/auth.py');
 871 |      var controller = scope.$new(MyController);
 872 |      $httpBackend.flush();
 873 |    });
 874 | 
 875 | 
 876 |    it('should send msg to server', function() {
 877 |      // now you don’t care about the authentication, but
 878 |      // the controller will still send the request and
 879 |      // $httpBackend will respond without you having to
 880 |      // specify the expectation and response for this request
 881 |      $httpBackend.expectPOST('/add-msg.py', 'message content').respond(201, '');
 882 | 
 883 |      var controller = scope.$new(MyController);
 884 |      $httpBackend.flush();
 885 |      controller.saveMessage('message content');
 886 |      expect(controller.status).toBe('Saving...');
 887 |      $httpBackend.flush();
 888 |      expect(controller.status).toBe('');
 889 |    });
 890 | 
 891 | 
 892 |    it('should send auth header', function() {
 893 |      $httpBackend.expectPOST('/add-msg.py', undefined, function(headers) {
 894 |        // check if the header was send, if it wasn't the expectation won't
 895 |        // match the request and the test will fail
 896 |        return headers['Authorization'] == 'xxx';
 897 |      }).respond(201, '');
 898 | 
 899 |      var controller = scope.$new(MyController);
 900 |      controller.saveMessage('whatever');
 901 |      $httpBackend.flush();
 902 |    });
 903 |    
904 | */ 905 | angular.mock.$HttpBackendProvider = function() { 906 | this.$get = ['$rootScope', createHttpBackendMock]; 907 | }; 908 | 909 | /** 910 | * General factory function for $httpBackend mock. 911 | * Returns instance for unit testing (when no arguments specified): 912 | * - passing through is disabled 913 | * - auto flushing is disabled 914 | * 915 | * Returns instance for e2e testing (when `$delegate` and `$browser` specified): 916 | * - passing through (delegating request to real backend) is enabled 917 | * - auto flushing is enabled 918 | * 919 | * @param {Object=} $delegate Real $httpBackend instance (allow passing through if specified) 920 | * @param {Object=} $browser Auto-flushing enabled if specified 921 | * @return {Object} Instance of $httpBackend mock 922 | */ 923 | function createHttpBackendMock($rootScope, $delegate, $browser) { 924 | var definitions = [], 925 | expectations = [], 926 | responses = [], 927 | responsesPush = angular.bind(responses, responses.push); 928 | 929 | function createResponse(status, data, headers) { 930 | if (angular.isFunction(status)) return status; 931 | 932 | return function() { 933 | return angular.isNumber(status) 934 | ? [status, data, headers] 935 | : [200, status, data]; 936 | }; 937 | } 938 | 939 | // TODO(vojta): change params to: method, url, data, headers, callback 940 | function $httpBackend(method, url, data, callback, headers, timeout) { 941 | var xhr = new MockXhr(), 942 | expectation = expectations[0], 943 | wasExpected = false; 944 | 945 | function prettyPrint(data) { 946 | return (angular.isString(data) || angular.isFunction(data) || data instanceof RegExp) 947 | ? data 948 | : angular.toJson(data); 949 | } 950 | 951 | function wrapResponse(wrapped) { 952 | if (!$browser && timeout && timeout.then) timeout.then(handleTimeout); 953 | 954 | return handleResponse; 955 | 956 | function handleResponse() { 957 | var response = wrapped.response(method, url, data, headers); 958 | xhr.$$respHeaders = response[2]; 959 | callback(response[0], response[1], xhr.getAllResponseHeaders()); 960 | } 961 | 962 | function handleTimeout() { 963 | for (var i = 0, ii = responses.length; i < ii; i++) { 964 | if (responses[i] === handleResponse) { 965 | responses.splice(i, 1); 966 | callback(-1, undefined, ''); 967 | break; 968 | } 969 | } 970 | } 971 | } 972 | 973 | if (expectation && expectation.match(method, url)) { 974 | if (!expectation.matchData(data)) 975 | throw Error('Expected ' + expectation + ' with different data\n' + 976 | 'EXPECTED: ' + prettyPrint(expectation.data) + '\nGOT: ' + data); 977 | 978 | if (!expectation.matchHeaders(headers)) 979 | throw Error('Expected ' + expectation + ' with different headers\n' + 980 | 'EXPECTED: ' + prettyPrint(expectation.headers) + '\nGOT: ' + 981 | prettyPrint(headers)); 982 | 983 | expectations.shift(); 984 | 985 | if (expectation.response) { 986 | responses.push(wrapResponse(expectation)); 987 | return; 988 | } 989 | wasExpected = true; 990 | } 991 | 992 | var i = -1, definition; 993 | while ((definition = definitions[++i])) { 994 | if (definition.match(method, url, data, headers || {})) { 995 | if (definition.response) { 996 | // if $browser specified, we do auto flush all requests 997 | ($browser ? $browser.defer : responsesPush)(wrapResponse(definition)); 998 | } else if (definition.passThrough) { 999 | $delegate(method, url, data, callback, headers, timeout); 1000 | } else throw Error('No response defined !'); 1001 | return; 1002 | } 1003 | } 1004 | throw wasExpected ? 1005 | Error('No response defined !') : 1006 | Error('Unexpected request: ' + method + ' ' + url + '\n' + 1007 | (expectation ? 'Expected ' + expectation : 'No more request expected')); 1008 | } 1009 | 1010 | /** 1011 | * @ngdoc method 1012 | * @name ngMock.$httpBackend#when 1013 | * @methodOf ngMock.$httpBackend 1014 | * @description 1015 | * Creates a new backend definition. 1016 | * 1017 | * @param {string} method HTTP method. 1018 | * @param {string|RegExp} url HTTP url. 1019 | * @param {(string|RegExp)=} data HTTP request body. 1020 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1021 | * object and returns true if the headers match the current definition. 1022 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1023 | * request is handled. 1024 | * 1025 | * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` 1026 | * – The respond method takes a set of static data to be returned or a function that can return 1027 | * an array containing response status (number), response data (string) and response headers 1028 | * (Object). 1029 | */ 1030 | $httpBackend.when = function(method, url, data, headers) { 1031 | var definition = new MockHttpExpectation(method, url, data, headers), 1032 | chain = { 1033 | respond: function(status, data, headers) { 1034 | definition.response = createResponse(status, data, headers); 1035 | } 1036 | }; 1037 | 1038 | if ($browser) { 1039 | chain.passThrough = function() { 1040 | definition.passThrough = true; 1041 | }; 1042 | } 1043 | 1044 | definitions.push(definition); 1045 | return chain; 1046 | }; 1047 | 1048 | /** 1049 | * @ngdoc method 1050 | * @name ngMock.$httpBackend#whenGET 1051 | * @methodOf ngMock.$httpBackend 1052 | * @description 1053 | * Creates a new backend definition for GET requests. For more info see `when()`. 1054 | * 1055 | * @param {string|RegExp} url HTTP url. 1056 | * @param {(Object|function(Object))=} headers HTTP headers. 1057 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1058 | * request is handled. 1059 | */ 1060 | 1061 | /** 1062 | * @ngdoc method 1063 | * @name ngMock.$httpBackend#whenHEAD 1064 | * @methodOf ngMock.$httpBackend 1065 | * @description 1066 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1067 | * 1068 | * @param {string|RegExp} url HTTP url. 1069 | * @param {(Object|function(Object))=} headers HTTP headers. 1070 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1071 | * request is handled. 1072 | */ 1073 | 1074 | /** 1075 | * @ngdoc method 1076 | * @name ngMock.$httpBackend#whenDELETE 1077 | * @methodOf ngMock.$httpBackend 1078 | * @description 1079 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1080 | * 1081 | * @param {string|RegExp} url HTTP url. 1082 | * @param {(Object|function(Object))=} headers HTTP headers. 1083 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1084 | * request is handled. 1085 | */ 1086 | 1087 | /** 1088 | * @ngdoc method 1089 | * @name ngMock.$httpBackend#whenPOST 1090 | * @methodOf ngMock.$httpBackend 1091 | * @description 1092 | * Creates a new backend definition for POST requests. For more info see `when()`. 1093 | * 1094 | * @param {string|RegExp} url HTTP url. 1095 | * @param {(string|RegExp)=} data HTTP request body. 1096 | * @param {(Object|function(Object))=} headers HTTP headers. 1097 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1098 | * request is handled. 1099 | */ 1100 | 1101 | /** 1102 | * @ngdoc method 1103 | * @name ngMock.$httpBackend#whenPUT 1104 | * @methodOf ngMock.$httpBackend 1105 | * @description 1106 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1107 | * 1108 | * @param {string|RegExp} url HTTP url. 1109 | * @param {(string|RegExp)=} data HTTP request body. 1110 | * @param {(Object|function(Object))=} headers HTTP headers. 1111 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1112 | * request is handled. 1113 | */ 1114 | 1115 | /** 1116 | * @ngdoc method 1117 | * @name ngMock.$httpBackend#whenJSONP 1118 | * @methodOf ngMock.$httpBackend 1119 | * @description 1120 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1121 | * 1122 | * @param {string|RegExp} url HTTP url. 1123 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1124 | * request is handled. 1125 | */ 1126 | createShortMethods('when'); 1127 | 1128 | 1129 | /** 1130 | * @ngdoc method 1131 | * @name ngMock.$httpBackend#expect 1132 | * @methodOf ngMock.$httpBackend 1133 | * @description 1134 | * Creates a new request expectation. 1135 | * 1136 | * @param {string} method HTTP method. 1137 | * @param {string|RegExp} url HTTP url. 1138 | * @param {(string|RegExp)=} data HTTP request body. 1139 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1140 | * object and returns true if the headers match the current expectation. 1141 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1142 | * request is handled. 1143 | * 1144 | * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` 1145 | * – The respond method takes a set of static data to be returned or a function that can return 1146 | * an array containing response status (number), response data (string) and response headers 1147 | * (Object). 1148 | */ 1149 | $httpBackend.expect = function(method, url, data, headers) { 1150 | var expectation = new MockHttpExpectation(method, url, data, headers); 1151 | expectations.push(expectation); 1152 | return { 1153 | respond: function(status, data, headers) { 1154 | expectation.response = createResponse(status, data, headers); 1155 | } 1156 | }; 1157 | }; 1158 | 1159 | 1160 | /** 1161 | * @ngdoc method 1162 | * @name ngMock.$httpBackend#expectGET 1163 | * @methodOf ngMock.$httpBackend 1164 | * @description 1165 | * Creates a new request expectation for GET requests. For more info see `expect()`. 1166 | * 1167 | * @param {string|RegExp} url HTTP url. 1168 | * @param {Object=} headers HTTP headers. 1169 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1170 | * request is handled. See #expect for more info. 1171 | */ 1172 | 1173 | /** 1174 | * @ngdoc method 1175 | * @name ngMock.$httpBackend#expectHEAD 1176 | * @methodOf ngMock.$httpBackend 1177 | * @description 1178 | * Creates a new request expectation for HEAD requests. For more info see `expect()`. 1179 | * 1180 | * @param {string|RegExp} url HTTP url. 1181 | * @param {Object=} headers HTTP headers. 1182 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1183 | * request is handled. 1184 | */ 1185 | 1186 | /** 1187 | * @ngdoc method 1188 | * @name ngMock.$httpBackend#expectDELETE 1189 | * @methodOf ngMock.$httpBackend 1190 | * @description 1191 | * Creates a new request expectation for DELETE requests. For more info see `expect()`. 1192 | * 1193 | * @param {string|RegExp} url HTTP url. 1194 | * @param {Object=} headers HTTP headers. 1195 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1196 | * request is handled. 1197 | */ 1198 | 1199 | /** 1200 | * @ngdoc method 1201 | * @name ngMock.$httpBackend#expectPOST 1202 | * @methodOf ngMock.$httpBackend 1203 | * @description 1204 | * Creates a new request expectation for POST requests. For more info see `expect()`. 1205 | * 1206 | * @param {string|RegExp} url HTTP url. 1207 | * @param {(string|RegExp)=} data HTTP request body. 1208 | * @param {Object=} headers HTTP headers. 1209 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1210 | * request is handled. 1211 | */ 1212 | 1213 | /** 1214 | * @ngdoc method 1215 | * @name ngMock.$httpBackend#expectPUT 1216 | * @methodOf ngMock.$httpBackend 1217 | * @description 1218 | * Creates a new request expectation for PUT requests. For more info see `expect()`. 1219 | * 1220 | * @param {string|RegExp} url HTTP url. 1221 | * @param {(string|RegExp)=} data HTTP request body. 1222 | * @param {Object=} headers HTTP headers. 1223 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1224 | * request is handled. 1225 | */ 1226 | 1227 | /** 1228 | * @ngdoc method 1229 | * @name ngMock.$httpBackend#expectPATCH 1230 | * @methodOf ngMock.$httpBackend 1231 | * @description 1232 | * Creates a new request expectation for PATCH requests. For more info see `expect()`. 1233 | * 1234 | * @param {string|RegExp} url HTTP url. 1235 | * @param {(string|RegExp)=} data HTTP request body. 1236 | * @param {Object=} headers HTTP headers. 1237 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1238 | * request is handled. 1239 | */ 1240 | 1241 | /** 1242 | * @ngdoc method 1243 | * @name ngMock.$httpBackend#expectJSONP 1244 | * @methodOf ngMock.$httpBackend 1245 | * @description 1246 | * Creates a new request expectation for JSONP requests. For more info see `expect()`. 1247 | * 1248 | * @param {string|RegExp} url HTTP url. 1249 | * @returns {requestHandler} Returns an object with `respond` method that control how a matched 1250 | * request is handled. 1251 | */ 1252 | createShortMethods('expect'); 1253 | 1254 | 1255 | /** 1256 | * @ngdoc method 1257 | * @name ngMock.$httpBackend#flush 1258 | * @methodOf ngMock.$httpBackend 1259 | * @description 1260 | * Flushes all pending requests using the trained responses. 1261 | * 1262 | * @param {number=} count Number of responses to flush (in the order they arrived). If undefined, 1263 | * all pending requests will be flushed. If there are no pending requests when the flush method 1264 | * is called an exception is thrown (as this typically a sign of programming error). 1265 | */ 1266 | $httpBackend.flush = function(count) { 1267 | $rootScope.$digest(); 1268 | if (!responses.length) throw Error('No pending request to flush !'); 1269 | 1270 | if (angular.isDefined(count)) { 1271 | while (count--) { 1272 | if (!responses.length) throw Error('No more pending request to flush !'); 1273 | responses.shift()(); 1274 | } 1275 | } else { 1276 | while (responses.length) { 1277 | responses.shift()(); 1278 | } 1279 | } 1280 | $httpBackend.verifyNoOutstandingExpectation(); 1281 | }; 1282 | 1283 | 1284 | /** 1285 | * @ngdoc method 1286 | * @name ngMock.$httpBackend#verifyNoOutstandingExpectation 1287 | * @methodOf ngMock.$httpBackend 1288 | * @description 1289 | * Verifies that all of the requests defined via the `expect` api were made. If any of the 1290 | * requests were not made, verifyNoOutstandingExpectation throws an exception. 1291 | * 1292 | * Typically, you would call this method following each test case that asserts requests using an 1293 | * "afterEach" clause. 1294 | * 1295 | *
1296 |    *   afterEach($httpBackend.verifyExpectations);
1297 |    * 
1298 | */ 1299 | $httpBackend.verifyNoOutstandingExpectation = function() { 1300 | $rootScope.$digest(); 1301 | if (expectations.length) { 1302 | throw Error('Unsatisfied requests: ' + expectations.join(', ')); 1303 | } 1304 | }; 1305 | 1306 | 1307 | /** 1308 | * @ngdoc method 1309 | * @name ngMock.$httpBackend#verifyNoOutstandingRequest 1310 | * @methodOf ngMock.$httpBackend 1311 | * @description 1312 | * Verifies that there are no outstanding requests that need to be flushed. 1313 | * 1314 | * Typically, you would call this method following each test case that asserts requests using an 1315 | * "afterEach" clause. 1316 | * 1317 | *
1318 |    *   afterEach($httpBackend.verifyNoOutstandingRequest);
1319 |    * 
1320 | */ 1321 | $httpBackend.verifyNoOutstandingRequest = function() { 1322 | if (responses.length) { 1323 | throw Error('Unflushed requests: ' + responses.length); 1324 | } 1325 | }; 1326 | 1327 | 1328 | /** 1329 | * @ngdoc method 1330 | * @name ngMock.$httpBackend#resetExpectations 1331 | * @methodOf ngMock.$httpBackend 1332 | * @description 1333 | * Resets all request expectations, but preserves all backend definitions. Typically, you would 1334 | * call resetExpectations during a multiple-phase test when you want to reuse the same instance of 1335 | * $httpBackend mock. 1336 | */ 1337 | $httpBackend.resetExpectations = function() { 1338 | expectations.length = 0; 1339 | responses.length = 0; 1340 | }; 1341 | 1342 | return $httpBackend; 1343 | 1344 | 1345 | function createShortMethods(prefix) { 1346 | angular.forEach(['GET', 'DELETE', 'JSONP'], function(method) { 1347 | $httpBackend[prefix + method] = function(url, headers) { 1348 | return $httpBackend[prefix](method, url, undefined, headers) 1349 | } 1350 | }); 1351 | 1352 | angular.forEach(['PUT', 'POST', 'PATCH'], function(method) { 1353 | $httpBackend[prefix + method] = function(url, data, headers) { 1354 | return $httpBackend[prefix](method, url, data, headers) 1355 | } 1356 | }); 1357 | } 1358 | } 1359 | 1360 | function MockHttpExpectation(method, url, data, headers) { 1361 | 1362 | this.data = data; 1363 | this.headers = headers; 1364 | 1365 | this.match = function(m, u, d, h) { 1366 | if (method != m) return false; 1367 | if (!this.matchUrl(u)) return false; 1368 | if (angular.isDefined(d) && !this.matchData(d)) return false; 1369 | if (angular.isDefined(h) && !this.matchHeaders(h)) return false; 1370 | return true; 1371 | }; 1372 | 1373 | this.matchUrl = function(u) { 1374 | if (!url) return true; 1375 | if (angular.isFunction(url.test)) return url.test(u); 1376 | return url == u; 1377 | }; 1378 | 1379 | this.matchHeaders = function(h) { 1380 | if (angular.isUndefined(headers)) return true; 1381 | if (angular.isFunction(headers)) return headers(h); 1382 | return angular.equals(headers, h); 1383 | }; 1384 | 1385 | this.matchData = function(d) { 1386 | if (angular.isUndefined(data)) return true; 1387 | if (data && angular.isFunction(data.test)) return data.test(d); 1388 | if (data && !angular.isString(data)) return angular.toJson(data) == d; 1389 | return data == d; 1390 | }; 1391 | 1392 | this.toString = function() { 1393 | return method + ' ' + url; 1394 | }; 1395 | } 1396 | 1397 | function MockXhr() { 1398 | 1399 | // hack for testing $http, $httpBackend 1400 | MockXhr.$$lastInstance = this; 1401 | 1402 | this.open = function(method, url, async) { 1403 | this.$$method = method; 1404 | this.$$url = url; 1405 | this.$$async = async; 1406 | this.$$reqHeaders = {}; 1407 | this.$$respHeaders = {}; 1408 | }; 1409 | 1410 | this.send = function(data) { 1411 | this.$$data = data; 1412 | }; 1413 | 1414 | this.setRequestHeader = function(key, value) { 1415 | this.$$reqHeaders[key] = value; 1416 | }; 1417 | 1418 | this.getResponseHeader = function(name) { 1419 | // the lookup must be case insensitive, that's why we try two quick lookups and full scan at last 1420 | var header = this.$$respHeaders[name]; 1421 | if (header) return header; 1422 | 1423 | name = angular.lowercase(name); 1424 | header = this.$$respHeaders[name]; 1425 | if (header) return header; 1426 | 1427 | header = undefined; 1428 | angular.forEach(this.$$respHeaders, function(headerVal, headerName) { 1429 | if (!header && angular.lowercase(headerName) == name) header = headerVal; 1430 | }); 1431 | return header; 1432 | }; 1433 | 1434 | this.getAllResponseHeaders = function() { 1435 | var lines = []; 1436 | 1437 | angular.forEach(this.$$respHeaders, function(value, key) { 1438 | lines.push(key + ': ' + value); 1439 | }); 1440 | return lines.join('\n'); 1441 | }; 1442 | 1443 | this.abort = angular.noop; 1444 | } 1445 | 1446 | 1447 | /** 1448 | * @ngdoc function 1449 | * @name ngMock.$timeout 1450 | * @description 1451 | * 1452 | * This service is just a simple decorator for {@link ng.$timeout $timeout} service 1453 | * that adds a "flush" and "verifyNoPendingTasks" methods. 1454 | */ 1455 | 1456 | angular.mock.$TimeoutDecorator = function($delegate, $browser) { 1457 | 1458 | /** 1459 | * @ngdoc method 1460 | * @name ngMock.$timeout#flush 1461 | * @methodOf ngMock.$timeout 1462 | * @description 1463 | * 1464 | * Flushes the queue of pending tasks. 1465 | */ 1466 | $delegate.flush = function() { 1467 | $browser.defer.flush(); 1468 | }; 1469 | 1470 | /** 1471 | * @ngdoc method 1472 | * @name ngMock.$timeout#verifyNoPendingTasks 1473 | * @methodOf ngMock.$timeout 1474 | * @description 1475 | * 1476 | * Verifies that there are no pending tasks that need to be flushed. 1477 | */ 1478 | $delegate.verifyNoPendingTasks = function() { 1479 | if ($browser.deferredFns.length) { 1480 | throw Error('Deferred tasks to flush (' + $browser.deferredFns.length + '): ' + 1481 | formatPendingTasksAsString($browser.deferredFns)); 1482 | } 1483 | }; 1484 | 1485 | function formatPendingTasksAsString(tasks) { 1486 | var result = []; 1487 | angular.forEach(tasks, function(task) { 1488 | result.push('{id: ' + task.id + ', ' + 'time: ' + task.time + '}'); 1489 | }); 1490 | 1491 | return result.join(', '); 1492 | } 1493 | 1494 | return $delegate; 1495 | }; 1496 | 1497 | /** 1498 | * 1499 | */ 1500 | angular.mock.$RootElementProvider = function() { 1501 | this.$get = function() { 1502 | return angular.element('
'); 1503 | } 1504 | }; 1505 | 1506 | /** 1507 | * @ngdoc overview 1508 | * @name ngMock 1509 | * @description 1510 | * 1511 | * The `ngMock` is an angular module which is used with `ng` module and adds unit-test configuration as well as useful 1512 | * mocks to the {@link AUTO.$injector $injector}. 1513 | */ 1514 | angular.module('ngMock', ['ng']).provider({ 1515 | $browser: angular.mock.$BrowserProvider, 1516 | $exceptionHandler: angular.mock.$ExceptionHandlerProvider, 1517 | $log: angular.mock.$LogProvider, 1518 | $httpBackend: angular.mock.$HttpBackendProvider, 1519 | $rootElement: angular.mock.$RootElementProvider 1520 | }).config(function($provide) { 1521 | $provide.decorator('$timeout', angular.mock.$TimeoutDecorator); 1522 | }); 1523 | 1524 | /** 1525 | * @ngdoc overview 1526 | * @name ngMockE2E 1527 | * @description 1528 | * 1529 | * The `ngMockE2E` is an angular module which contains mocks suitable for end-to-end testing. 1530 | * Currently there is only one mock present in this module - 1531 | * the {@link ngMockE2E.$httpBackend e2e $httpBackend} mock. 1532 | */ 1533 | angular.module('ngMockE2E', ['ng']).config(function($provide) { 1534 | $provide.decorator('$httpBackend', angular.mock.e2e.$httpBackendDecorator); 1535 | }); 1536 | 1537 | /** 1538 | * @ngdoc object 1539 | * @name ngMockE2E.$httpBackend 1540 | * @description 1541 | * Fake HTTP backend implementation suitable for end-to-end testing or backend-less development of 1542 | * applications that use the {@link ng.$http $http service}. 1543 | * 1544 | * *Note*: For fake http backend implementation suitable for unit testing please see 1545 | * {@link ngMock.$httpBackend unit-testing $httpBackend mock}. 1546 | * 1547 | * This implementation can be used to respond with static or dynamic responses via the `when` api 1548 | * and its shortcuts (`whenGET`, `whenPOST`, etc) and optionally pass through requests to the 1549 | * real $httpBackend for specific requests (e.g. to interact with certain remote apis or to fetch 1550 | * templates from a webserver). 1551 | * 1552 | * As opposed to unit-testing, in an end-to-end testing scenario or in scenario when an application 1553 | * is being developed with the real backend api replaced with a mock, it is often desirable for 1554 | * certain category of requests to bypass the mock and issue a real http request (e.g. to fetch 1555 | * templates or static files from the webserver). To configure the backend with this behavior 1556 | * use the `passThrough` request handler of `when` instead of `respond`. 1557 | * 1558 | * Additionally, we don't want to manually have to flush mocked out requests like we do during unit 1559 | * testing. For this reason the e2e $httpBackend automatically flushes mocked out requests 1560 | * automatically, closely simulating the behavior of the XMLHttpRequest object. 1561 | * 1562 | * To setup the application to run with this http backend, you have to create a module that depends 1563 | * on the `ngMockE2E` and your application modules and defines the fake backend: 1564 | * 1565 | *
1566 |  *   myAppDev = angular.module('myAppDev', ['myApp', 'ngMockE2E']);
1567 |  *   myAppDev.run(function($httpBackend) {
1568 |  *     phones = [{name: 'phone1'}, {name: 'phone2'}];
1569 |  *
1570 |  *     // returns the current list of phones
1571 |  *     $httpBackend.whenGET('/phones').respond(phones);
1572 |  *
1573 |  *     // adds a new phone to the phones array
1574 |  *     $httpBackend.whenPOST('/phones').respond(function(method, url, data) {
1575 |  *       phones.push(angular.fromJSON(data));
1576 |  *     });
1577 |  *     $httpBackend.whenGET(/^\/templates\//).passThrough();
1578 |  *     //...
1579 |  *   });
1580 |  * 
1581 | * 1582 | * Afterwards, bootstrap your app with this new module. 1583 | */ 1584 | 1585 | /** 1586 | * @ngdoc method 1587 | * @name ngMockE2E.$httpBackend#when 1588 | * @methodOf ngMockE2E.$httpBackend 1589 | * @description 1590 | * Creates a new backend definition. 1591 | * 1592 | * @param {string} method HTTP method. 1593 | * @param {string|RegExp} url HTTP url. 1594 | * @param {(string|RegExp)=} data HTTP request body. 1595 | * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header 1596 | * object and returns true if the headers match the current definition. 1597 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1598 | * control how a matched request is handled. 1599 | * 1600 | * - respond – `{function([status,] data[, headers])|function(function(method, url, data, headers)}` 1601 | * – The respond method takes a set of static data to be returned or a function that can return 1602 | * an array containing response status (number), response data (string) and response headers 1603 | * (Object). 1604 | * - passThrough – `{function()}` – Any request matching a backend definition with `passThrough` 1605 | * handler, will be pass through to the real backend (an XHR request will be made to the 1606 | * server. 1607 | */ 1608 | 1609 | /** 1610 | * @ngdoc method 1611 | * @name ngMockE2E.$httpBackend#whenGET 1612 | * @methodOf ngMockE2E.$httpBackend 1613 | * @description 1614 | * Creates a new backend definition for GET requests. For more info see `when()`. 1615 | * 1616 | * @param {string|RegExp} url HTTP url. 1617 | * @param {(Object|function(Object))=} headers HTTP headers. 1618 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1619 | * control how a matched request is handled. 1620 | */ 1621 | 1622 | /** 1623 | * @ngdoc method 1624 | * @name ngMockE2E.$httpBackend#whenHEAD 1625 | * @methodOf ngMockE2E.$httpBackend 1626 | * @description 1627 | * Creates a new backend definition for HEAD requests. For more info see `when()`. 1628 | * 1629 | * @param {string|RegExp} url HTTP url. 1630 | * @param {(Object|function(Object))=} headers HTTP headers. 1631 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1632 | * control how a matched request is handled. 1633 | */ 1634 | 1635 | /** 1636 | * @ngdoc method 1637 | * @name ngMockE2E.$httpBackend#whenDELETE 1638 | * @methodOf ngMockE2E.$httpBackend 1639 | * @description 1640 | * Creates a new backend definition for DELETE requests. For more info see `when()`. 1641 | * 1642 | * @param {string|RegExp} url HTTP url. 1643 | * @param {(Object|function(Object))=} headers HTTP headers. 1644 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1645 | * control how a matched request is handled. 1646 | */ 1647 | 1648 | /** 1649 | * @ngdoc method 1650 | * @name ngMockE2E.$httpBackend#whenPOST 1651 | * @methodOf ngMockE2E.$httpBackend 1652 | * @description 1653 | * Creates a new backend definition for POST requests. For more info see `when()`. 1654 | * 1655 | * @param {string|RegExp} url HTTP url. 1656 | * @param {(string|RegExp)=} data HTTP request body. 1657 | * @param {(Object|function(Object))=} headers HTTP headers. 1658 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1659 | * control how a matched request is handled. 1660 | */ 1661 | 1662 | /** 1663 | * @ngdoc method 1664 | * @name ngMockE2E.$httpBackend#whenPUT 1665 | * @methodOf ngMockE2E.$httpBackend 1666 | * @description 1667 | * Creates a new backend definition for PUT requests. For more info see `when()`. 1668 | * 1669 | * @param {string|RegExp} url HTTP url. 1670 | * @param {(string|RegExp)=} data HTTP request body. 1671 | * @param {(Object|function(Object))=} headers HTTP headers. 1672 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1673 | * control how a matched request is handled. 1674 | */ 1675 | 1676 | /** 1677 | * @ngdoc method 1678 | * @name ngMockE2E.$httpBackend#whenPATCH 1679 | * @methodOf ngMockE2E.$httpBackend 1680 | * @description 1681 | * Creates a new backend definition for PATCH requests. For more info see `when()`. 1682 | * 1683 | * @param {string|RegExp} url HTTP url. 1684 | * @param {(string|RegExp)=} data HTTP request body. 1685 | * @param {(Object|function(Object))=} headers HTTP headers. 1686 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1687 | * control how a matched request is handled. 1688 | */ 1689 | 1690 | /** 1691 | * @ngdoc method 1692 | * @name ngMockE2E.$httpBackend#whenJSONP 1693 | * @methodOf ngMockE2E.$httpBackend 1694 | * @description 1695 | * Creates a new backend definition for JSONP requests. For more info see `when()`. 1696 | * 1697 | * @param {string|RegExp} url HTTP url. 1698 | * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that 1699 | * control how a matched request is handled. 1700 | */ 1701 | angular.mock.e2e = {}; 1702 | angular.mock.e2e.$httpBackendDecorator = ['$rootScope', '$delegate', '$browser', createHttpBackendMock]; 1703 | 1704 | 1705 | angular.mock.clearDataCache = function() { 1706 | var key, 1707 | cache = angular.element.cache; 1708 | 1709 | for(key in cache) { 1710 | if (cache.hasOwnProperty(key)) { 1711 | var handle = cache[key].handle; 1712 | 1713 | handle && angular.element(handle.elem).unbind(); 1714 | delete cache[key]; 1715 | } 1716 | } 1717 | }; 1718 | 1719 | 1720 | window.jstestdriver && (function(window) { 1721 | /** 1722 | * Global method to output any number of objects into JSTD console. Useful for debugging. 1723 | */ 1724 | window.dump = function() { 1725 | var args = []; 1726 | angular.forEach(arguments, function(arg) { 1727 | args.push(angular.mock.dump(arg)); 1728 | }); 1729 | jstestdriver.console.log.apply(jstestdriver.console, args); 1730 | if (window.console) { 1731 | window.console.log.apply(window.console, args); 1732 | } 1733 | }; 1734 | })(window); 1735 | 1736 | 1737 | (window.jasmine || window.mocha) && (function(window) { 1738 | 1739 | var currentSpec = null; 1740 | 1741 | beforeEach(function() { 1742 | currentSpec = this; 1743 | }); 1744 | 1745 | afterEach(function() { 1746 | var injector = currentSpec.$injector; 1747 | 1748 | currentSpec.$injector = null; 1749 | currentSpec.$modules = null; 1750 | currentSpec = null; 1751 | 1752 | if (injector) { 1753 | injector.get('$rootElement').unbind(); 1754 | injector.get('$browser').pollFns.length = 0; 1755 | } 1756 | 1757 | angular.mock.clearDataCache(); 1758 | 1759 | // clean up jquery's fragment cache 1760 | angular.forEach(angular.element.fragments, function(val, key) { 1761 | delete angular.element.fragments[key]; 1762 | }); 1763 | 1764 | MockXhr.$$lastInstance = null; 1765 | 1766 | angular.forEach(angular.callbacks, function(val, key) { 1767 | delete angular.callbacks[key]; 1768 | }); 1769 | angular.callbacks.counter = 0; 1770 | }); 1771 | 1772 | function isSpecRunning() { 1773 | return currentSpec && (window.mocha || currentSpec.queue.running); 1774 | } 1775 | 1776 | /** 1777 | * @ngdoc function 1778 | * @name angular.mock.module 1779 | * @description 1780 | * 1781 | * *NOTE*: This function is also published on window for easy access.
1782 | * 1783 | * This function registers a module configuration code. It collects the configuration information 1784 | * which will be used when the injector is created by {@link angular.mock.inject inject}. 1785 | * 1786 | * See {@link angular.mock.inject inject} for usage example 1787 | * 1788 | * @param {...(string|Function)} fns any number of modules which are represented as string 1789 | * aliases or as anonymous module initialization functions. The modules are used to 1790 | * configure the injector. The 'ng' and 'ngMock' modules are automatically loaded. 1791 | */ 1792 | window.module = angular.mock.module = function() { 1793 | var moduleFns = Array.prototype.slice.call(arguments, 0); 1794 | return isSpecRunning() ? workFn() : workFn; 1795 | ///////////////////// 1796 | function workFn() { 1797 | if (currentSpec.$injector) { 1798 | throw Error('Injector already created, can not register a module!'); 1799 | } else { 1800 | var modules = currentSpec.$modules || (currentSpec.$modules = []); 1801 | angular.forEach(moduleFns, function(module) { 1802 | modules.push(module); 1803 | }); 1804 | } 1805 | } 1806 | }; 1807 | 1808 | /** 1809 | * @ngdoc function 1810 | * @name angular.mock.inject 1811 | * @description 1812 | * 1813 | * *NOTE*: This function is also published on window for easy access.
1814 | * 1815 | * The inject function wraps a function into an injectable function. The inject() creates new 1816 | * instance of {@link AUTO.$injector $injector} per test, which is then used for 1817 | * resolving references. 1818 | * 1819 | * See also {@link angular.mock.module module} 1820 | * 1821 | * Example of what a typical jasmine tests looks like with the inject method. 1822 | *
1823 |    *
1824 |    *   angular.module('myApplicationModule', [])
1825 |    *       .value('mode', 'app')
1826 |    *       .value('version', 'v1.0.1');
1827 |    *
1828 |    *
1829 |    *   describe('MyApp', function() {
1830 |    *
1831 |    *     // You need to load modules that you want to test,
1832 |    *     // it loads only the "ng" module by default.
1833 |    *     beforeEach(module('myApplicationModule'));
1834 |    *
1835 |    *
1836 |    *     // inject() is used to inject arguments of all given functions
1837 |    *     it('should provide a version', inject(function(mode, version) {
1838 |    *       expect(version).toEqual('v1.0.1');
1839 |    *       expect(mode).toEqual('app');
1840 |    *     }));
1841 |    *
1842 |    *
1843 |    *     // The inject and module method can also be used inside of the it or beforeEach
1844 |    *     it('should override a version and test the new version is injected', function() {
1845 |    *       // module() takes functions or strings (module aliases)
1846 |    *       module(function($provide) {
1847 |    *         $provide.value('version', 'overridden'); // override version here
1848 |    *       });
1849 |    *
1850 |    *       inject(function(version) {
1851 |    *         expect(version).toEqual('overridden');
1852 |    *       });
1853 |    *     ));
1854 |    *   });
1855 |    *
1856 |    * 
1857 | * 1858 | * @param {...Function} fns any number of functions which will be injected using the injector. 1859 | */ 1860 | window.inject = angular.mock.inject = function() { 1861 | var blockFns = Array.prototype.slice.call(arguments, 0); 1862 | var errorForStack = new Error('Declaration Location'); 1863 | return isSpecRunning() ? workFn() : workFn; 1864 | ///////////////////// 1865 | function workFn() { 1866 | var modules = currentSpec.$modules || []; 1867 | 1868 | modules.unshift('ngMock'); 1869 | modules.unshift('ng'); 1870 | var injector = currentSpec.$injector; 1871 | if (!injector) { 1872 | injector = currentSpec.$injector = angular.injector(modules); 1873 | } 1874 | for(var i = 0, ii = blockFns.length; i < ii; i++) { 1875 | try { 1876 | injector.invoke(blockFns[i] || angular.noop, this); 1877 | } catch (e) { 1878 | if(e.stack && errorForStack) e.stack += '\n' + errorForStack.stack; 1879 | throw e; 1880 | } finally { 1881 | errorForStack = null; 1882 | } 1883 | } 1884 | } 1885 | }; 1886 | })(window); --------------------------------------------------------------------------------