├── .bowerrc ├── .gitignore ├── .jshintrc ├── .nvmrc ├── .travis.yml ├── Gemfile ├── Gruntfile.js ├── LICENSE ├── README.md ├── angular-widget.sublime-project ├── app ├── app1-dev.html ├── app2-dev.html ├── app3-dev.html ├── images │ └── angularWidget.png ├── index.html ├── scripts │ ├── app.js │ ├── controllers │ │ ├── app1.js │ │ ├── app2.js │ │ ├── app3.js │ │ ├── widget1.js │ │ └── widget2.js │ ├── demo.js │ ├── directives │ │ └── ng-widget.js │ └── services │ │ ├── file-loader.js │ │ ├── tag-appender.js │ │ ├── widget-config.js │ │ └── widgets.js ├── styles │ ├── app1.scss │ ├── app2.scss │ ├── app3.scss │ ├── demo.scss │ ├── widget1.scss │ └── widget2.scss ├── views │ ├── app1.html │ ├── app2.html │ ├── app3.html │ ├── widget1.html │ └── widget2.html ├── widget1-dev.html └── widget2-dev.html ├── bower.json ├── karma.conf.js ├── package.json ├── pom.xml ├── protractor-conf.js └── test ├── .jshintrc ├── mock ├── mock-lazyloaded-file.js └── server-api.js └── spec ├── app.spec.js ├── controllers ├── app1.spec.js └── main.spec.js ├── demo.spec.js ├── directives └── ng-widget.spec.js ├── e2e └── scenarios.js └── services ├── file-loader.spec.js ├── tag-appender.spec.js ├── widget-config.spec.js └── widgets.spec.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "app/bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .tmp 4 | .sass-cache 5 | app/bower_components 6 | *.iml 7 | *.sublime-workspace 8 | npm-debug.log 9 | sauce_connect.log* 10 | .DS_Store 11 | .bundle 12 | .idea 13 | .sauce-connect 14 | vendor 15 | coverage 16 | target 17 | replace.private.conf.js 18 | reference.ts 19 | .baseDir.ts 20 | .tscache 21 | Gemfile.lock 22 | /angular-widget.js 23 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "camelcase": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "immed": true, 7 | "indent": 2, 8 | "latedef": true, 9 | "newcap": true, 10 | "noarg": true, 11 | "quotmark": "single", 12 | "regexp": true, 13 | "undef": true, 14 | "unused": true, 15 | "strict": true, 16 | "globalstrict": true, 17 | "trailing": true, 18 | "smarttabs": true, 19 | "white": true, 20 | "globals": { 21 | "_": false, 22 | "angular": false, 23 | "require": false, 24 | "process": false, 25 | "module": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | 5 | install: 6 | - npm install 7 | - bundle install 8 | 9 | before_script: 10 | - npm install -g grunt-cli bower 11 | - bower install 12 | 13 | script: 14 | - grunt build:ci 15 | 16 | after_success: 17 | - cat ./coverage/*/lcov.info | ./node_modules/coveralls/bin/coveralls.js 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'compass' 4 | gem 'haml' 5 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | // Generated on 2014-05-23 using generator-wix-angular 0.1.50 2 | 'use strict'; 3 | 4 | module.exports = function (grunt) { 5 | var unitTestFiles = []; 6 | require('./karma.conf.js')({set: function (karmaConf) { 7 | unitTestFiles = karmaConf.files.filter(function (value) { 8 | return !value.indexOf || value.indexOf('bower_component') !== -1; 9 | }); 10 | }}); 11 | require('wix-gruntfile')(grunt, { 12 | port: 9000, 13 | preloadModule: 'angularWidgetApp', 14 | unitTestFiles: unitTestFiles, 15 | protractor: true, 16 | bowerComponent: true, 17 | proxies: { 18 | html5mode: function (req, res) { 19 | res.end(grunt.file.read('app/index.html')); 20 | } 21 | } 22 | }); 23 | 24 | grunt.modifyTask('yeoman', { 25 | local: 'http://localhost:<%= connect.options.port %>/' 26 | }); 27 | 28 | grunt.modifyTask('karma', { 29 | teamcity: { 30 | coverageReporter: {dir : 'coverage/', type: 'lcov'} 31 | } 32 | }); 33 | 34 | grunt.modifyTask('copy', { 35 | js: { 36 | expand: true, 37 | cwd: 'dist/scripts', 38 | dest: '', 39 | src: 'angular-widget.js' 40 | } 41 | }); 42 | 43 | grunt.hookTask('package').push('copy:js'); 44 | 45 | process.env.USE_JASMINE2 = 'true'; 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Wix.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Angular Widget [![Build Status](https://travis-ci.org/wix/angular-widget.svg?branch=master)](https://travis-ci.org/wix/angular-widget) [![Coverage Status](https://coveralls.io/repos/wix/angular-widget/badge.png?branch=master)](https://coveralls.io/r/wix/angular-widget?branch=master) 2 | ================ 3 | 4 | Lazy load isolated micro-apps in [Angular](http://www.angularjs.org) 5 | 6 | Demo: http://shahata.github.io/angular-widget/ 7 | 8 | Slides: https://slides.com/shahartalmi/angular-widget/ 9 | 10 | Talk (English - Bad quality): https://youtu.be/D8fOHIwz8mY 11 | 12 | Talk (Hebrew): http://youtu.be/Wgn2Vid8zCA 13 | 14 | ## What it does 15 | 16 | One of the main problems people discover in angular when they try to write a very big application with all sorts of components, is that you can't load code during run-time easily. When angular bootstraps your DOM, it creates an injector with the configuration that was defined at that moment and you cannot add services, directives, controllers, etc. easily after the injector was created. This library allows you to download js/css/html code into a running angular app and will create a new injector for that "widget". Since each widget has its own injector, each widget has a different instance for services that they use. They can configure them how ever they like without any effect on any other widget or on the main application that hosts the widgets. Regardless, all widgets share the same DOM, so a widget create modal dialogs or whatever it likes. Widgets are simply added to the DOM using the `ng-widget` directive. The directive download all required files and creates the widget. Widgets can get information from the hosting application using the `options` attribute of the directive and they can report to the hosting application using `widgetConfig.exportProperties` and `widgetConfig.reportError`. 17 | 18 | But that's just the start! Widgets can actually be full blown applications with their own router. (both angular-route and ui-router are supported) See the demo page for an example that uses angular-route to host three lazy loaded applications - one uses ui-router internally, one uses angular-route and the third displays some widget (widgets within widgets, whoa...) 19 | 20 | See https://github.com/wix/angular-widget/blob/master/app/scripts/demo.js for an example of how you would typically configure a hosting application to run multiple lazy loaded applications on different routes. 21 | 22 | ## Installation 23 | 24 | Install using bower 25 | 26 | `bower install --save angular-widget` 27 | 28 | Include script tag in your html document. 29 | 30 | ```html 31 | 32 | ``` 33 | 34 | Add a dependency to your application module. 35 | 36 | ```javascript 37 | angular.module('myApp', ['angularWidget']); 38 | ``` 39 | 40 | ## Directive Usage 41 | 42 | ```html 43 | 44 | ``` 45 | 46 | ### Arguments 47 | 48 | |Param|Type|Details| 49 | |---|---|---| 50 | |src|string|Name of widget to download. This is resolved to a module name, html file url and js/css file url list when the directive invokes `widgets.getWidgetManifest(src)` (more on this soon)| 51 | |options (optional)|object|An object of options which might effect the behavior of the widget. The widget gets those options by calling `widgetConfig.getOptions()` (more on this soon)| 52 | |delay (optional)|number|Well, that's pretty silly, but with widgets you sometimes want to let the user feel that the widget is actually loading. So you can add a delay with this param| 53 | 54 | ### Events 55 | 56 | The directive emits the following events: 57 | 58 | |Param|Details| 59 | |---|---| 60 | |widgetLoading|Sent when the widget loading starts | 61 | |widgetLoaded|Sent when the widget is done loading. This happens when all the files were downloaded and the new DOM node was bootstrapped. In case the widget itself wants to postpone sending this event until it is done initializing, it can optionally call `widgetConfig.exportProperties({loading: true}` in a run block and then call `widgetConfig.exportProperties({loading: false }` when done | 62 | |widgetError|Sent when some download or bootstrap fails. Called also when the widget calls `widgetConfig.reportErrror()`| 63 | |exportPropertiesUpdated|Sent along with the updated properties when the widget calls `widgetConfig.exportProperties()`| 64 | 65 | The directive will reload the widget if it receives a `reloadWidget` event from a parent scope. 66 | 67 | ## Service Usage (hosting application) 68 | 69 | ```js 70 | angular.module('myApp').config(function (widgetsProvider) { 71 | widgetsProvider.setManifestGenerator(['dep1', 'dep1', function (dep1, dep2) { 72 | return function (name) { 73 | return { 74 | module: name + 'Widget', 75 | config: [], //optional array of extra modules to load into the new injector 76 | priority: 1, //optional priority for conflicting generators 77 | html: 'views/' + name + '.html', 78 | files: [ 79 | 'scripts/controllers/' + name + '.js', 80 | 'styles/' + name + '.css' 81 | ] 82 | }; 83 | }; 84 | }]); 85 | }) 86 | ``` 87 | 88 | ### Arguments 89 | 90 | You must set the manifest generator function in order for the directive to work. This is how we can know for a specific plugin, what files should be loaded and with which module name to create the injector. Above you can see an example for a manifest generator, but you can do whatever you like. You can put both relative and absolute URL's, of course. 91 | 92 | *Note:* In case [requirejs](http://requirejs.org/) is available in the global scope, it will be used to load the javascript files. So if your widget needs more than one js file, you can include [requirejs](http://requirejs.org/) and use AMD to load them. 93 | 94 | You can actually set multiple manifest generators and they will be evaluated in the order that they were defined. So a generator is allowed to return `undefined` in case it simply wants a different generator to handle it. The way the generators responses are handled is that the last generator that didn't return `undefined` will be used, unless a different generator returned a result with higher `priority`. 95 | 96 | ## Service Usage (widget) 97 | 98 | In order to communicate with the hosting application, the widget uses the `widgetConfig` service. (the widget module always has a module dependency on `angularWidget`, if no such dependency exists, it will be added automatically during bootstrap) 99 | 100 | ### Methods 101 | 102 | |Name|Param|Details| 103 | |---|---|---| 104 | |exportProperties|properties (obj)|Send information to the hosting application. The object sent in the param will extend previous calls, so you can send only the properties that have changed. The directive will emit a `exportPropertiesUpdated` event. | 105 | |reportError|N/A|Report some kind of error to the hosting application. The directive will emit a `widgetError` event. | 106 | |getOptions|N/A|Get the options object supplied by the object. The options will always have the same reference, so you can save it on the scope. A scope digest will be triggered automatically if the options change. | 107 | 108 | ## Sharing information between widgets 109 | 110 | In some cases it might be needed to share some global state between widgets. When this global state changes you'll probably need to run a digest cycle in all widgets. `$rootScope.$digest()` will run the digest only in the injector which owns that `$rootScope` instance. To run `$rootScope.$digest()` on all `$rootScope` instances in all widgets, use `widgtes.notifyWidgtes()`. 111 | 112 | Also, if you want to broadcast an event on the `$rootScope` of all widgets, just call `widgets.notifyWidgets(eventName, args, ...)`. It returns an array of the scope event that were dispatched. 113 | 114 | ### Sharing services 115 | 116 | As mentioned before, each widget has its very own injector, which means that each widget has its own service instances which are isolated from the services of other widgets and of the hosting application. A nice way to share information between widgets and the the hosting application is to have a shared service instance. So you can ask angular-widget to have the service shared pretty easily by running `widgetsProvider.addServiceToShare(name, description)` in a config section of the hosting application - `name` is the name of the service you want to share, that's pretty obvious, but `description` (optional) is a bit more difficult to explain. 117 | 118 | It is important to remember that the widgets and hosting application do not share the same digest cycle, so if you are going to make a call to a shared service from a widget, you want to trigger a digest in all root scopes that share this service instance. You could just call `widgets.notifyWidgets()`, but an easier way would be to declare which methods of the shared service might change the state (no need to mention getters, for example) and have angular-widget do the rest for you. So `description` can be an array of such method names, or it can be an object where the method name is the key and the minimum amount of arguments is the value. The object option should be used when you have something like methods that behave as getters when they have no arguments and as setters when they have one arguments. (in this case you would pass `{methodName: 1}` as `description`) 119 | 120 | BTW, one service that is shared by default in order for angular-widget to work is `$location`. 121 | 122 | ### Sharing events 123 | 124 | One more important option to share information between widgets and the main application are scope events. Since the widgets have a different injector, their root scope is isolated from scope events in different injectors, but this can easily be changed by adding `widgetsProvider.addEventToForward(name)` in a config section of the hosting application. This will make the `ngWidget` directive propagate events with this name to the widget's root scope. The widget may call `preventDefault()` on the event in order to prevent the default behavior of the original event. 125 | 126 | BTW, one event that is shared by default is `$locationChangeStart`. This is in order to allow widgets to `preventDefault()` and display some "you have unsaved changes" dialog if they want to. If the user decides to continue, the widget can do something like `$location.$$parse(absUrl)`. (`absUrl` is the first parameter passed along with the `$locationChangeStart` event) Calling `$$parse` will trigger a digest in the hosting application automatically as described in the previous section, since this service is shared by default. 127 | 128 | ## How to use in the real world 129 | 130 | This framework is best used by having a separate project for each widget. During development, the developer sees only his own widget. All widgets should be built in a consistent manner, usually with one concatenated minified .js and .css files. 131 | 132 | ## License 133 | 134 | The MIT License. 135 | 136 | See [LICENSE](https://github.com/shahata/angular-widget/blob/master/LICENSE) 137 | -------------------------------------------------------------------------------- /angular-widget.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "path": ".", 6 | "folder_exclude_patterns": [".tmp", ".sass-cache", "dist", "coverage", "node_modules", ".bundle", "vendor", ".tscache"] 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /app/app1-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/app2-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app2 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/app3-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | app3 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/images/angularWidget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/angular-widget/12ff1be07e97d93afe95135ec82e9efab065879d/app/images/angularWidget.png -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | angularWidgetDemo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | {{app}} 17 | {{navigationCount}}

18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/scripts/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularWidgetInternal', []); 4 | 5 | angular.module('angularWidget', ['angularWidgetInternal']) 6 | .config(function ($provide, $injector) { 7 | if (!$injector.has('$routeProvider')) { 8 | return; 9 | } 10 | //this is a really ugly trick to prevent reloading of the ng-view in case 11 | //an internal route changes, effecting only the router inside that view. 12 | $provide.decorator('$rootScope', function ($delegate, $injector) { 13 | var next, last, originalBroadcast = $delegate.$broadcast; 14 | var lastAbsUrl = ''; 15 | 16 | //sending $locationChangeSuccess will cause another $routeUpdate 17 | //so we need this ugly flag to prevent call stack overflow 18 | var suspendListener = false; 19 | 20 | function suspendedNotify(widgets, $location) { 21 | suspendListener = true; 22 | var absUrl = $location.absUrl(); 23 | widgets.notifyWidgets('$locationChangeStart', absUrl, lastAbsUrl); 24 | widgets.notifyWidgets('$locationChangeSuccess', absUrl, lastAbsUrl); 25 | lastAbsUrl = absUrl; 26 | suspendListener = false; 27 | } 28 | 29 | $delegate.$broadcast = function (name) { 30 | var shouldMute = false; 31 | if (name === '$routeChangeSuccess') { 32 | $injector.invoke(/* @ngInject */function ($route, widgets, $location) { 33 | last = next; 34 | next = $route.current; 35 | if (next && last && next.$$route === last.$$route && 36 | !next.$$route.reloadOnSearch && 37 | next.locals && next.locals.$template && 38 | next.locals.$template.indexOf('ng-view / ' + applicationName + '' 12 | }); 13 | }); 14 | 15 | $routeProvider.otherwise({ 16 | redirectTo: '/app2/sub1' 17 | }); 18 | }); -------------------------------------------------------------------------------- /app/scripts/controllers/app3.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('app3', ['ui.router']).config(function ($stateProvider, $urlRouterProvider) { 4 | var colors = { 5 | sub1: 'pink', 6 | sub2: 'purple', 7 | sub3: 'yellow' 8 | }; 9 | ['sub1', 'sub2', 'sub3'].forEach(function (applicationName) { 10 | $stateProvider.state(applicationName, { 11 | url: '/app3/' + applicationName, 12 | template: '
ui-view / ' + applicationName + '
' 13 | }); 14 | }); 15 | 16 | $urlRouterProvider.otherwise('/app3/sub1'); 17 | }); -------------------------------------------------------------------------------- /app/scripts/controllers/widget1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('widget1', ['angularWidget']) 4 | .controller('Widget1Ctrl', function ($scope, widgetConfig) { 5 | widgetConfig.exportProperties({title: 'widget1 title'}); 6 | $scope.widgetOptions = widgetConfig.getOptions(); 7 | $scope.awesomeThings = [ 8 | 'Item 11', 9 | 'Item 12', 10 | 'Item 13', 11 | 'Item 14', 12 | 'Item 15' 13 | ]; 14 | }); 15 | -------------------------------------------------------------------------------- /app/scripts/controllers/widget2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('widget2', ['angularWidget']) 4 | .controller('Widget2Ctrl', function ($scope, widgetConfig) { 5 | widgetConfig.exportProperties({title: 'widget2 title'}); 6 | $scope.widgetOptions = widgetConfig.getOptions(); 7 | $scope.awesomeThings = [ 8 | 'Item 21', 9 | 'Item 22', 10 | 'Item 23', 11 | 'Item 24', 12 | 'Item 25' 13 | ]; 14 | }); 15 | -------------------------------------------------------------------------------- /app/scripts/demo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularWidgetApp', ['ngRoute', 'angularWidget']) 4 | // .config(function setHtml5Mode($locationProvider) { 5 | // $locationProvider.html5Mode(true); 6 | // }) 7 | .config(function initializeRouteProvider($routeProvider) { 8 | ['app1', 'app2', 'app3'].forEach(function (applicationName) { 9 | $routeProvider.when('/' + applicationName + '/:eatall*?', { 10 | template: '', 11 | reloadOnSearch: false 12 | }); 13 | }); 14 | 15 | $routeProvider.otherwise({ 16 | redirectTo: '/app1/' 17 | }); 18 | }) 19 | .config(function initializemanifestGenerator(widgetsProvider) { 20 | widgetsProvider.setManifestGenerator(function () { 21 | return function (name) { 22 | return { 23 | module: name, 24 | html: 'views/' + name + '.html', 25 | files: [ 26 | 'scripts/controllers/' + name + '.js', 27 | 'styles/' + name + '.css' 28 | ] 29 | }; 30 | }; 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /app/scripts/directives/ng-widget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularWidgetInternal') 4 | .directive('ngWidget', function ($http, $templateCache, $q, $timeout, fileLoader, widgets, $rootScope, $log, $browser) { 5 | return { 6 | restrict: 'E', 7 | priority: 999, 8 | terminal: true, 9 | scope: { 10 | src: '=', 11 | options: '=', 12 | delay: '@' 13 | }, 14 | link: function (scope, element) { 15 | var changeCounter = 0, injector, unsubscribe; 16 | 17 | /* @ngInject */ 18 | function widgetConfigSection($provide, widgetConfigProvider) { 19 | //force the widget to use the shared service instead of creating some instance 20 | //since this is using a constant (which is available during config) to steal the 21 | //show, we can theoretically use it to share providers, but that's for another day. 22 | angular.forEach(widgets.getServicesToShare(), function (value, key) { 23 | $provide.constant(key, value); 24 | }); 25 | 26 | widgetConfigProvider.setParentInjectorScope(scope); 27 | widgetConfigProvider.setOptions(scope.options); 28 | } 29 | 30 | function whenTimeout(result, delay) { 31 | return delay ? $timeout(function () { 32 | return result; 33 | }, delay) : result; 34 | } 35 | 36 | function delayedPromise(promise, delay) { 37 | return $q.when(promise).then(function (result) { 38 | return whenTimeout(result, delay); 39 | }, function (result) { 40 | return whenTimeout($q.reject(result), delay); 41 | }); 42 | } 43 | 44 | function moduleAlreadyExists(name) { 45 | try { 46 | //testing requires instead of only module just so we can control if this happens in tests 47 | return !!angular.module(name).requires.length; 48 | } catch (e) { 49 | return false; 50 | } 51 | } 52 | 53 | function downloadWidget(module, html, filetags) { 54 | var promises = [$http.get(html, {cache: $templateCache})]; 55 | if (!moduleAlreadyExists(module)) { 56 | promises.push(fileLoader.loadFiles(filetags)); 57 | } 58 | return $q.all(promises).then(function (result) { 59 | return result[0].data; 60 | }); 61 | } 62 | 63 | function forwardEvent(name, src, dst, emit) { 64 | var fn = emit ? dst.$emit : dst.$broadcast; 65 | return src.$on(name, function (event) { 66 | if ((!emit && !event.stopPropagation) || (emit && event.stopPropagation)) { 67 | var args = Array.prototype.slice.call(arguments); 68 | args[0] = name; 69 | if (dst.$root.$$phase) { 70 | applyHandler(fn, dst, args, event); 71 | } else { 72 | dst.$apply(function () { 73 | applyHandler(fn, dst, args, event); 74 | }); 75 | } 76 | } 77 | }); 78 | } 79 | 80 | function applyHandler(fn, dst, args, event) { 81 | if (fn.apply(dst, args).defaultPrevented) { 82 | event.preventDefault(); 83 | } 84 | } 85 | 86 | function handleNewInjector() { 87 | var widgetConfig = injector.get('widgetConfig'); 88 | var widgetScope = injector.get('$rootScope'); 89 | unsubscribe = []; 90 | 91 | widgets.getEventsToForward().forEach(function (name) { 92 | unsubscribe.push(forwardEvent(name, $rootScope, widgetScope, false)); 93 | unsubscribe.push(forwardEvent(name, widgetScope, scope, true)); 94 | }); 95 | 96 | unsubscribe.push(scope.$watch('options', function (options) { 97 | widgetScope.$apply(function () { 98 | widgetConfig.setOptions(options); 99 | }); 100 | }, true)); 101 | 102 | var properties = widgetConfig.exportProperties(); 103 | if (!properties.loading) { 104 | scope.$emit('widgetLoaded', scope.src); 105 | } else { 106 | var deregister = scope.$on('exportPropertiesUpdated', function (event, properties) { 107 | if (!properties.loading) { 108 | deregister(); 109 | scope.$emit('widgetLoaded', scope.src); 110 | } 111 | }); 112 | unsubscribe.push(deregister); 113 | } 114 | 115 | widgets.registerWidget(injector); 116 | } 117 | 118 | function bootstrapWidget(src, delay) { 119 | var thisChangeId = ++changeCounter; 120 | $q.when(widgets.getWidgetManifest(src)).then(function (manifest) { 121 | $browser.$$incOutstandingRequestCount(); 122 | delayedPromise(downloadWidget(manifest.module, manifest.html, manifest.files), delay) 123 | .then(function (response) { 124 | if (thisChangeId !== changeCounter) { 125 | return; 126 | } 127 | try { 128 | var widgetElement = angular.element(response); 129 | var modules = [ 130 | 'angularWidgetOnly', 131 | 'angularWidget', 132 | widgetConfigSection, 133 | manifest.module 134 | ].concat(manifest.config || []); 135 | 136 | scope.$emit('widgetLoading'); 137 | injector = angular.bootstrap(widgetElement, modules); 138 | handleNewInjector(); 139 | element.append(widgetElement); 140 | } catch (e) { 141 | $log.error(e); 142 | scope.$emit('widgetError'); 143 | } 144 | }).catch(function () { 145 | if (thisChangeId === changeCounter) { 146 | scope.$emit('widgetError'); 147 | } 148 | }).finally(function () { 149 | $browser.$$completeOutstandingRequest(angular.noop); 150 | }); 151 | }); 152 | } 153 | 154 | function unregisterInjector() { 155 | if (injector) { 156 | unsubscribe.forEach(function (fn) { 157 | fn(); 158 | }); 159 | widgets.unregisterWidget(injector); 160 | injector = null; 161 | unsubscribe = []; 162 | } 163 | } 164 | 165 | function updateWidgetSrc() { 166 | unregisterInjector(); 167 | element.html(''); 168 | if (scope.src) { 169 | bootstrapWidget(scope.src, scope.delay); 170 | } 171 | } 172 | 173 | scope.$watch('src', updateWidgetSrc); 174 | scope.$on('reloadWidget', updateWidgetSrc); 175 | scope.$on('$destroy', function () { 176 | changeCounter++; 177 | unregisterInjector(); 178 | element.html(''); 179 | }); 180 | } 181 | }; 182 | }); 183 | -------------------------------------------------------------------------------- /app/scripts/services/file-loader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | /* @ngInject */ 5 | function FileLoader(tagAppender, $q) { 6 | 7 | function loadSequentially(filenames) { 8 | return filenames.reduce(function (previousPromise, filename) { 9 | return previousPromise.then(function () { 10 | return tagAppender(filename, filename.split('.').reverse()[0]); 11 | }); 12 | }, $q.when()); 13 | } 14 | 15 | this.loadFiles = function (fileNames) { 16 | return $q.all(fileNames.map(function (filename) { 17 | return Array.isArray(filename) ? 18 | loadSequentially(filename) : 19 | tagAppender(filename, filename.split('.').reverse()[0]); 20 | })); 21 | }; 22 | } 23 | 24 | angular 25 | .module('angularWidgetInternal') 26 | .service('fileLoader', FileLoader); 27 | 28 | })(); 29 | -------------------------------------------------------------------------------- /app/scripts/services/tag-appender.js: -------------------------------------------------------------------------------- 1 | /* global navigator, document, window */ 2 | 'use strict'; 3 | 4 | angular.module('angularWidgetInternal') 5 | .value('headElement', document.getElementsByTagName('head')[0]) 6 | .factory('requirejs', function () { 7 | return window.requirejs || null; 8 | }) 9 | .value('navigator', navigator) 10 | .factory('tagAppender', function ($q, $rootScope, headElement, $interval, navigator, $document, requirejs, $browser) { 11 | var requireCache = {}; 12 | var styleSheets = $document[0].styleSheets; 13 | 14 | function noprotocol(url) { 15 | return url.replace(/^.*:\/\//, '//'); 16 | } 17 | 18 | return function (url, filetype) { 19 | var deferred = $q.defer(); 20 | deferred.promise.finally(function () { 21 | $browser.$$completeOutstandingRequest(angular.noop); 22 | }); 23 | $browser.$$incOutstandingRequestCount(); 24 | if (requirejs && filetype === 'js') { 25 | requirejs([url], function (module) { 26 | $rootScope.$apply(function () { 27 | deferred.resolve(module); 28 | }); 29 | }, function (err) { 30 | $rootScope.$apply(function () { 31 | deferred.reject(err); 32 | }); 33 | }); 34 | return deferred.promise; 35 | } 36 | if (url in requireCache) { 37 | requireCache[url].then(function (res) { 38 | deferred.resolve(res); 39 | }, function (res) { 40 | deferred.reject(res); 41 | }); 42 | return deferred.promise; 43 | } 44 | requireCache[url] = deferred.promise; 45 | 46 | var fileref; 47 | if (filetype === 'css') { 48 | fileref = angular.element('')[0]; 49 | fileref.setAttribute('rel', 'stylesheet'); 50 | fileref.setAttribute('type', 'text/css'); 51 | fileref.setAttribute('href', url); 52 | } else { 53 | fileref = angular.element('')[0]; 54 | fileref.setAttribute('type', 'text/javascript'); 55 | fileref.setAttribute('src', url); 56 | } 57 | 58 | var done = false; 59 | headElement.appendChild(fileref); 60 | fileref.onerror = function () { 61 | fileref.onerror = fileref.onload = fileref.onreadystatechange = null; 62 | delete requireCache[url]; 63 | //the $$phase test is required due to $interval mock, should be removed when $interval is fixed 64 | if ($rootScope.$$phase) { 65 | deferred.reject(); 66 | } else { 67 | $rootScope.$apply(function () { 68 | deferred.reject(); 69 | }); 70 | } 71 | }; 72 | fileref.onload = fileref.onreadystatechange = function () { 73 | if (!done && (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete')) { 74 | done = true; 75 | fileref.onerror = fileref.onload = fileref.onreadystatechange = null; 76 | 77 | //the $$phase test is required due to $interval mock, should be removed when $interval is fixed 78 | if ($rootScope.$$phase) { 79 | deferred.resolve(); 80 | } else { 81 | $rootScope.$apply(function () { 82 | deferred.resolve(); 83 | }); 84 | } 85 | } 86 | }; 87 | if (filetype === 'css' && navigator.userAgent.match(' Safari/') && !navigator.userAgent.match(' Chrom') && navigator.userAgent.match(' Version/5.')) { 88 | var attempts = 20; 89 | var promise = $interval(function checkStylesheetAttempt() { 90 | for (var i = 0; i < styleSheets.length; i++) { 91 | if (noprotocol(styleSheets[i].href + '') === noprotocol(url)) { 92 | $interval.cancel(promise); 93 | fileref.onload(); 94 | return; 95 | } 96 | } 97 | if (--attempts === 0) { 98 | $interval.cancel(promise); 99 | fileref.onerror(); 100 | } 101 | }, 50, 0); //need to be false in order to not invoke apply... ($interval bug) 102 | } 103 | 104 | return deferred.promise; 105 | }; 106 | }); 107 | -------------------------------------------------------------------------------- /app/scripts/services/widget-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularWidgetInternal') 4 | .provider('widgetConfig', function () { 5 | var defaultParentInjectorScope = { 6 | $root: {}, 7 | $apply: function (fn) { fn(); }, 8 | $emit: angular.noop 9 | }; 10 | 11 | var parentInjectorScope = defaultParentInjectorScope; 12 | 13 | var options = {}; 14 | 15 | this.setParentInjectorScope = function (scope) { 16 | parentInjectorScope = scope; 17 | var unsubscribe = parentInjectorScope.$on('$destroy', function () { 18 | parentInjectorScope = defaultParentInjectorScope; 19 | unsubscribe(); 20 | }); 21 | }; 22 | 23 | this.setOptions = function (newOptions) { 24 | angular.copy(newOptions, options); 25 | }; 26 | 27 | this.getOptions = function () { 28 | return options; 29 | }; 30 | 31 | function safeApply(fn) { 32 | if (parentInjectorScope.$root.$$phase) { 33 | fn(); 34 | } else { 35 | parentInjectorScope.$apply(fn); 36 | } 37 | } 38 | 39 | this.$get = function ($log) { 40 | var properties = {}; 41 | 42 | return { 43 | exportProperties: function (props) { 44 | if (props) { 45 | safeApply(function () { 46 | angular.extend(properties, props); 47 | parentInjectorScope.$emit('exportPropertiesUpdated', properties); 48 | }); 49 | } 50 | return properties; 51 | }, 52 | reportError: function () { 53 | safeApply(function () { 54 | if (!parentInjectorScope.$emit('widgetError')) { 55 | $log.warn('widget reported an error'); 56 | } 57 | }); 58 | }, 59 | getOptions: function () { 60 | return options; 61 | }, 62 | setOptions: function (newOptions) { 63 | angular.copy(newOptions, options); 64 | } 65 | }; 66 | }; 67 | }); 68 | -------------------------------------------------------------------------------- /app/scripts/services/widgets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | angular.module('angularWidgetInternal') 3 | .provider('widgets', function () { 4 | var manifestGenerators = []; 5 | var eventsToForward = []; 6 | var servicesToShare = {}; 7 | 8 | this.setManifestGenerator = function (fn) { 9 | manifestGenerators.push(fn); 10 | }; 11 | 12 | this.addEventToForward = function (name) { 13 | eventsToForward = eventsToForward.concat(name); 14 | }; 15 | 16 | this.addServiceToShare = function (name, description) { 17 | servicesToShare[name] = description; 18 | }; 19 | 20 | this.$get = function ($injector, $rootScope) { 21 | var widgets = []; 22 | var instancesToShare = {}; 23 | 24 | //this will wrap setters so that we can run a digest loop in main app 25 | //after some shared service state is changed. 26 | function decorate(service, method, count) { 27 | var original = service[method]; 28 | service[method] = function () { 29 | if (arguments.length >= count && !$rootScope.$$phase) { 30 | $rootScope.$applyAsync(); 31 | } 32 | return original.apply(service, arguments); 33 | }; 34 | } 35 | 36 | angular.forEach(servicesToShare, function (description, name) { 37 | var service = $injector.get(name); 38 | if (angular.isArray(description)) { 39 | description.forEach(function (method) { 40 | decorate(service, method, 0); 41 | }); 42 | } else { 43 | angular.forEach(description, function (count, method) { 44 | decorate(service, method, count); 45 | }); 46 | } 47 | instancesToShare[name] = service; 48 | }); 49 | 50 | function notifyInjector(injector, args) { 51 | var scope = injector.get('$rootScope'); 52 | var event; 53 | if (args.length) { 54 | event = scope.$broadcast.apply(scope, args); 55 | } 56 | if (!scope.$$phase && injector !== $injector) { 57 | scope.$digest(); 58 | } 59 | return event; 60 | } 61 | 62 | manifestGenerators = manifestGenerators.map(function (generator) { 63 | return $injector.invoke(generator); 64 | }); 65 | 66 | return { 67 | getWidgetManifest: function () { 68 | var args = arguments; 69 | return manifestGenerators.reduce(function (prev, generator) { 70 | var result = generator.apply(this, args); 71 | if (result && prev) { 72 | //take the manifest with higher priority. 73 | //if same priority, last generator wins. 74 | return prev.priority > result.priority ? prev : result; 75 | } else { 76 | return result || prev; 77 | } 78 | }, undefined); 79 | }, 80 | unregisterWidget: function (injector) { 81 | var del = []; 82 | if (injector) { 83 | var i = widgets.indexOf(injector); 84 | if (i !== -1) { 85 | del = widgets.splice(i, 1); 86 | } 87 | } else { 88 | del = widgets; 89 | widgets = []; 90 | } 91 | del.forEach(function (injector) { 92 | var $rootScope = injector.get('$rootScope'); 93 | $rootScope.$destroy(); 94 | $rootScope.$$childHead = $rootScope.$$childTail = null; 95 | $rootScope.$$ChildScope = null; 96 | }); 97 | }, 98 | registerWidget: function (injector) { 99 | widgets.push(injector); 100 | }, 101 | notifyWidgets: function () { 102 | var args = arguments; 103 | return widgets.map(function (injector) { 104 | return notifyInjector(injector, args); 105 | }); 106 | }, 107 | getEventsToForward: function () { 108 | return eventsToForward; 109 | }, 110 | getServicesToShare: function () { 111 | return instancesToShare; 112 | } 113 | }; 114 | }; 115 | }); 116 | -------------------------------------------------------------------------------- /app/styles/app1.scss: -------------------------------------------------------------------------------- 1 | .app1 { 2 | display: block; 3 | } -------------------------------------------------------------------------------- /app/styles/app2.scss: -------------------------------------------------------------------------------- 1 | .app2 { 2 | display: block; 3 | } -------------------------------------------------------------------------------- /app/styles/app3.scss: -------------------------------------------------------------------------------- 1 | .app3 { 2 | display: block; 3 | } -------------------------------------------------------------------------------- /app/styles/demo.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wix-incubator/angular-widget/12ff1be07e97d93afe95135ec82e9efab065879d/app/styles/demo.scss -------------------------------------------------------------------------------- /app/styles/widget1.scss: -------------------------------------------------------------------------------- 1 | .widget1 { 2 | position: relative; 3 | border-radius: 6px; 4 | background-color: #eeeeee; 5 | padding: 60px; 6 | width: 100px; 7 | font-size: 18px; 8 | font-weight: 200; 9 | 10 | h1 { 11 | line-height: 1; 12 | letter-spacing: -1px; 13 | font-size: 60px; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /app/styles/widget2.scss: -------------------------------------------------------------------------------- 1 | .widget2 { 2 | position: relative; 3 | border-radius: 6px; 4 | background-color: #eeeeee; 5 | padding: 60px; 6 | width: 100px; 7 | font-size: 18px; 8 | font-weight: 200; 9 | 10 | h1 { 11 | line-height: 1; 12 | letter-spacing: -1px; 13 | font-size: 60px; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /app/views/app1.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |


9 | 10 |
11 |
12 | 13 | 14 |
LOADING...
15 |
ERROR...
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /app/views/app2.html: -------------------------------------------------------------------------------- 1 |
2 | {{app}} 5 | {{navigationCount}}

6 | 7 |
8 |
-------------------------------------------------------------------------------- /app/views/app3.html: -------------------------------------------------------------------------------- 1 |
2 | {{app}} 5 | {{navigationCount}}

6 | 7 |
8 |
-------------------------------------------------------------------------------- /app/views/widget1.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | -------------------------------------------------------------------------------- /app/views/widget2.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | -------------------------------------------------------------------------------- /app/widget1-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | widget1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/widget2-dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | widget2 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-widget", 3 | "version": "0.1.62", 4 | "main": "angular-widget.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/wix/angular-widget.git" 8 | }, 9 | "ignore": [ 10 | ".*", 11 | "Gemfile*", 12 | "Gruntfile.js", 13 | "pom.xml", 14 | "*.json", 15 | "*conf.js", 16 | "app", 17 | "dist", 18 | "test", 19 | "maven", 20 | "*.sublime*" 21 | ], 22 | "dependencies": { 23 | "angular": "~1.5.0" 24 | }, 25 | "devDependencies": { 26 | "jquery": "~2.0.3", 27 | "angular-mocks": "~1.5.0", 28 | "es5-shim": "~2.1.0", 29 | "angular-route": "~1.5.0", 30 | "angular-ui-router": "~0.2.11", 31 | "angular-widget": "~0.1.30", 32 | "requirejs": "~2.1.19" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // http://karma-runner.github.io/0.10/config/configuration-file.html 3 | 'use strict'; 4 | 5 | module.exports = function (config) { 6 | config.set({ 7 | plugins: ['karma-jasmine', 'karma-coverage', 'karma-phantomjs-launcher', 'karma-ng-html2js-preprocessor'], 8 | 9 | preprocessors: { 10 | '{.tmp,app}/scripts/{,!(lib)/**/}*.js': 'coverage', 11 | '{app,.tmp}/views/**/*.html': 'ng-html2js' 12 | }, 13 | 14 | ngHtml2JsPreprocessor: { 15 | stripPrefix: '(app|.tmp)/', 16 | moduleName: 'angularWidgetApp' 17 | }, 18 | 19 | // base path, that will be used to resolve files and exclude 20 | basePath: '', 21 | 22 | // testing framework to use (jasmine/mocha/qunit/...) 23 | frameworks: ['jasmine'], 24 | 25 | // list of files / patterns to load in the browser 26 | files: [ 27 | 'app/bower_components/jquery/jquery.js', 28 | 'app/bower_components/angular/angular.js', 29 | 'app/bower_components/angular-route/angular-route.js', 30 | 'app/bower_components/angular-ui-router/release/angular-ui-router.js', 31 | 'app/bower_components/angular-mocks/angular-mocks.js', 32 | 'app/bower_components/es5-shim/es5-shim.js', 33 | '{app,.tmp}/*.js', 34 | '{app,.tmp}/scripts/*.js', 35 | '{app,.tmp}/scripts/*/**/*.js', 36 | {pattern: '{,.tmp}/test/mock/mock-lazyloaded-file.js', included: false}, 37 | '{,.tmp/}test/**/*.js', 38 | '{app,.tmp}/views/**/*.html', 39 | 'app/bower_components/requirejs/require.js' 40 | ], 41 | 42 | // list of files / patterns to exclude 43 | exclude: [ 44 | '{,.tmp/}test/spec/e2e/*.js', 45 | '{app,.tmp}/scripts/locale/*_!(en).js' 46 | ], 47 | 48 | // test results reporter to use 49 | // possible values: dots || progress || growl 50 | reporters: ['progress', 'coverage'], 51 | 52 | // web server port 53 | port: 8880, 54 | 55 | // level of logging 56 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 57 | logLevel: config.LOG_INFO, 58 | 59 | // enable / disable watching file and executing tests whenever any file changes 60 | autoWatch: false, 61 | 62 | // Start these browsers, currently available: 63 | // - Chrome 64 | // - ChromeCanary 65 | // - Firefox 66 | // - Opera 67 | // - Safari (only Mac) 68 | // - PhantomJS 69 | // - IE (only Windows) 70 | browsers: ['PhantomJS'], 71 | 72 | // Continuous Integration mode 73 | // if true, it capture browsers, run tests and exit 74 | singleRun: true 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-widget", 3 | "version": "1.0.0", 4 | "dependencies": {}, 5 | "devDependencies": { 6 | "coveralls": "^2.10.0", 7 | "wix-gruntfile": "~0.1.2" 8 | }, 9 | "optionalDependencies": { 10 | "wix-statics-parent": "*" 11 | }, 12 | "publishConfig": { 13 | "registry": "http://repo.dev.wix/artifactory/api/npm/npm-local/" 14 | }, 15 | "engines": { 16 | "node": ">=0.8.0" 17 | }, 18 | "scripts": { 19 | "build": "node_modules/wix-gruntfile/scripts/build.sh", 20 | "release": "node_modules/wix-gruntfile/scripts/release.sh", 21 | "test": "#tbd", 22 | "start": "grunt serve" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "private": false 28 | } 29 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.wixpress.cx 5 | angular-widget 6 | jar 7 | angular-widget 8 | 1.0.0-SNAPSHOT 9 | lazy load angular micro apps 10 | 11 | 12 | Shahar Talmi 13 | shahar@wix.com 14 | 15 | owner 16 | 17 | 18 | 19 | 20 | com.wixpress 21 | wix-statics-parent 22 | 1.0.0-SNAPSHOT 23 | 24 | 25 | 26 | 27 | org.apache.maven.plugins 28 | maven-assembly-plugin 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /protractor-conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('./node_modules/wix-gruntfile/protractor-conf').config; 4 | 5 | config.capabilities = { 6 | browserName: 'chrome' 7 | }; 8 | 9 | module.exports.config = config; 10 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "camelcase": true, 4 | "curly": true, 5 | "eqeqeq": true, 6 | "immed": true, 7 | "indent": 2, 8 | "latedef": true, 9 | "newcap": true, 10 | "noarg": true, 11 | "quotmark": "single", 12 | "regexp": true, 13 | "undef": true, 14 | "unused": true, 15 | "strict": true, 16 | "globalstrict": true, 17 | "trailing": true, 18 | "smarttabs": true, 19 | "white": true, 20 | "node": true, 21 | "globals": { 22 | "_": false, 23 | "$": false, 24 | "$$": false, 25 | "angular": false, 26 | "jasmine": false, 27 | "module": false, 28 | "inject": false, 29 | 30 | "describe": false, 31 | "it": false, 32 | "beforeEach": false, 33 | "afterEach": false, 34 | "expect": false, 35 | "spyOn": false, 36 | "runs": false, 37 | "waitsFor": false, 38 | 39 | "browser": false, 40 | "protractor": false, 41 | "by": false, 42 | "element": false, 43 | "input": false, 44 | "select": false, 45 | "binding": false, 46 | "repeater": false, 47 | "using": false, 48 | "pause": false, 49 | "resume": false, 50 | "sleep": false, 51 | "window": false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/mock/mock-lazyloaded-file.js: -------------------------------------------------------------------------------- 1 | window.lazyLoadingWorking = true; 2 | -------------------------------------------------------------------------------- /test/mock/server-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | angular.module('angularWidgetMocks', ['ngMockE2E']) 4 | .run(function ($httpBackend) { 5 | $httpBackend.whenGET(/.*/).passThrough(); 6 | $httpBackend.whenPOST(/.*/).passThrough(); 7 | $httpBackend.whenPUT(/.*/).passThrough(); 8 | $httpBackend.whenDELETE(/.*/).passThrough(); 9 | }); 10 | -------------------------------------------------------------------------------- /test/spec/app.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing angular widget only kickoff', function () { 4 | var locationChangeStartSpy, locationChangeSuccessSpy; 5 | 6 | beforeEach(function () { 7 | module('angularWidgetOnly'); 8 | locationChangeStartSpy = jasmine.createSpy('locationChangeStartSpy'); 9 | locationChangeSuccessSpy = jasmine.createSpy('locationChangeSuccessSpy'); 10 | inject(function ($rootScope) { 11 | $rootScope.$on('$locationChangeStart', locationChangeStartSpy); 12 | $rootScope.$on('$locationChangeSuccess', locationChangeSuccessSpy); 13 | }); 14 | }); 15 | 16 | it('should broadcast $locationChangeSuccess immediately', inject(function ($rootScope) { 17 | $rootScope.$digest(); 18 | expect(locationChangeSuccessSpy).toHaveBeenCalledWith(jasmine.any(Object), 'http://server/', ''); 19 | })); 20 | 21 | it('should broadcast $locationChangeStart immediately', inject(function ($rootScope) { 22 | $rootScope.$digest(); 23 | expect(locationChangeStartSpy).toHaveBeenCalledWith(jasmine.any(Object), 'http://server/', ''); 24 | })); 25 | 26 | it('should not broadcast $locationChangeSuccess if $locationChangeStart is prevented', inject(function ($rootScope) { 27 | locationChangeStartSpy.and.callFake(function (ev) { 28 | ev.preventDefault(); 29 | }); 30 | $rootScope.$digest(); 31 | expect(locationChangeStartSpy).toHaveBeenCalledWith(jasmine.any(Object), 'http://server/', ''); 32 | expect(locationChangeSuccessSpy).not.toHaveBeenCalled(); 33 | })); 34 | }); 35 | 36 | describe('Unit testing routing hacks', function () { 37 | var $route, notifyWidgets; 38 | 39 | beforeEach(function () { 40 | module('ngRoute'); 41 | module('angularWidget'); 42 | 43 | $route = {current: {$$route: {}}}; 44 | notifyWidgets = jasmine.createSpy('notifyWidgets'); 45 | module({ 46 | widgets: {notifyWidgets: notifyWidgets}, 47 | $route: $route 48 | }); 49 | }); 50 | 51 | it('should notify widgets on location change when route updates', inject(function ($rootScope) { 52 | notifyWidgets.and.callFake(function () { 53 | $rootScope.$broadcast('$routeUpdate'); 54 | }); 55 | $rootScope.$broadcast('$routeUpdate'); 56 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeStart', 'http://server/', ''); 57 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeSuccess', 'http://server/', ''); 58 | })); 59 | 60 | it('should notify location change with old url', inject(function ($rootScope, $location) { 61 | $rootScope.$broadcast('$routeUpdate'); 62 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeStart', 'http://server/', ''); 63 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeSuccess', 'http://server/', ''); 64 | 65 | $location.path('/bla'); 66 | $rootScope.$broadcast('$routeUpdate'); 67 | 68 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeStart', 'http://server/#/bla', 'http://server/'); 69 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeSuccess', 'http://server/#/bla', 'http://server/'); 70 | })); 71 | 72 | it('should mute route change if widget did not change', inject(function ($rootScope) { 73 | var eventSpy = jasmine.createSpy('$routeChangeSuccess'); 74 | var eventSpyMuted = jasmine.createSpy('$routeChangeMuted'); 75 | $route.current.locals = {$template: ''}; 76 | 77 | $rootScope.$on('$routeChangeSuccess', eventSpy); 78 | $rootScope.$on('$routeChangeMuted', eventSpyMuted); 79 | $rootScope.$broadcast('$routeChangeSuccess'); 80 | expect(eventSpy).toHaveBeenCalled(); 81 | eventSpy.calls.reset(); 82 | 83 | $rootScope.$broadcast('$routeChangeSuccess'); 84 | expect(eventSpy).not.toHaveBeenCalled(); 85 | expect(eventSpyMuted).toHaveBeenCalled(); 86 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeStart', 'http://server/', ''); 87 | expect(notifyWidgets).toHaveBeenCalledWith('$locationChangeSuccess', 'http://server/', ''); 88 | })); 89 | 90 | it('should not prevent route change if route reloads on search', inject(function ($rootScope) { 91 | var eventSpy = jasmine.createSpy('$routeChangeSuccess'); 92 | $route.current.locals = {$template: ''}; 93 | $route.current.$$route.reloadOnSearch = true; 94 | 95 | $rootScope.$on('$routeChangeSuccess', eventSpy); 96 | $rootScope.$broadcast('$routeChangeSuccess'); 97 | expect(eventSpy).toHaveBeenCalled(); 98 | eventSpy.calls.reset(); 99 | 100 | $rootScope.$broadcast('$routeChangeSuccess'); 101 | expect(eventSpy).toHaveBeenCalled(); 102 | })); 103 | 104 | it('should not prevent route change if widget changed', inject(function ($rootScope) { 105 | var eventSpy = jasmine.createSpy('$routeChangeSuccess'); 106 | $route.current.locals = {$template: ''}; 107 | 108 | $rootScope.$on('$routeChangeSuccess', eventSpy); 109 | $rootScope.$broadcast('$routeChangeSuccess'); 110 | expect(eventSpy).toHaveBeenCalled(); 111 | eventSpy.calls.reset(); 112 | 113 | $route.current = {$$route: {}}; 114 | $route.current.locals = {$template: ''}; 115 | $rootScope.$broadcast('$routeChangeSuccess'); 116 | expect(eventSpy).toHaveBeenCalled(); 117 | })); 118 | 119 | it('should not prevent route change if no widget in template', inject(function ($rootScope) { 120 | var eventSpy = jasmine.createSpy('$routeChangeSuccess'); 121 | $route.current.locals = {$template: ''}; 122 | 123 | $rootScope.$on('$routeChangeSuccess', eventSpy); 124 | $rootScope.$broadcast('$routeChangeSuccess'); 125 | expect(eventSpy).toHaveBeenCalled(); 126 | eventSpy.calls.reset(); 127 | 128 | $rootScope.$broadcast('$routeChangeSuccess'); 129 | expect(eventSpy).toHaveBeenCalled(); 130 | })); 131 | 132 | it('should pass all other events', inject(function ($rootScope) { 133 | var eventSpy = jasmine.createSpy('shahata'); 134 | $rootScope.$on('shahata', eventSpy); 135 | $rootScope.$broadcast('shahata'); 136 | expect(eventSpy).toHaveBeenCalled(); 137 | })); 138 | }); 139 | -------------------------------------------------------------------------------- /test/spec/controllers/app1.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing widget container', function () { 4 | 5 | beforeEach(function () { 6 | module('app1'); 7 | module('app2'); 8 | module('app3'); 9 | }); 10 | 11 | describe('widgetContainer', function () { 12 | it('should start as loading', inject(function ($controller, $rootScope) { 13 | $controller('widgetContainer', {$scope: $rootScope}); 14 | expect($rootScope.isLoading).toBeTruthy(); 15 | })); 16 | 17 | it('should set title', inject(function ($controller, $rootScope) { 18 | $controller('widgetContainer', {$scope: $rootScope}); 19 | $rootScope.$broadcast('exportPropertiesUpdated', {title: 'shahata'}); 20 | expect($rootScope.title).toBe('shahata'); 21 | })); 22 | 23 | it('should set loaded', inject(function ($controller, $rootScope) { 24 | $controller('widgetContainer', {$scope: $rootScope}); 25 | $rootScope.$broadcast('widgetLoaded'); 26 | expect($rootScope.isLoading).toBeFalsy(); 27 | expect($rootScope.isError).toBeFalsy(); 28 | })); 29 | 30 | it('should set error', inject(function ($controller, $rootScope) { 31 | $controller('widgetContainer', {$scope: $rootScope}); 32 | $rootScope.$broadcast('widgetError'); 33 | expect($rootScope.isLoading).toBeFalsy(); 34 | expect($rootScope.isError).toBeTruthy(); 35 | })); 36 | 37 | it('should reload', inject(function ($controller, $rootScope) { 38 | var spy = jasmine.createSpy('reload'); 39 | $controller('widgetContainer', {$scope: $rootScope}); 40 | $rootScope.$on('reloadWidget', spy); 41 | $rootScope.reload(); 42 | expect($rootScope.isLoading).toBeTruthy(); 43 | expect($rootScope.isError).toBeFalsy(); 44 | expect(spy).toHaveBeenCalled(); 45 | })); 46 | 47 | it('should have a manifest generator', inject(function (widgets) { 48 | expect(widgets.getWidgetManifest('shahata')).toEqual({ 49 | module: 'shahata', 50 | html: 'views/shahata.html', 51 | files: [ 52 | 'scripts/controllers/shahata.js', 53 | 'styles/shahata.css' 54 | ] 55 | }); 56 | })); 57 | }); 58 | 59 | describe('widgetsList', function () { 60 | it('should have one widget by default', inject(function ($controller, $rootScope) { 61 | $controller('widgetsList', {$scope: $rootScope}); 62 | expect($rootScope.widgets.length).toBe(1); 63 | })); 64 | 65 | it('should add one widget', inject(function ($controller, $rootScope) { 66 | $controller('widgetsList', {$scope: $rootScope}); 67 | $rootScope.addWidget('aaa', 1); 68 | expect($rootScope.widgets.length).toBe(2); 69 | })); 70 | 71 | it('should add one hundred widget', inject(function ($controller, $rootScope) { 72 | $controller('widgetsList', {$scope: $rootScope}); 73 | $rootScope.addWidget('aaa', 100); 74 | expect($rootScope.widgets.length).toBe(101); 75 | })); 76 | 77 | it('should restore one widget', inject(function ($controller, $rootScope) { 78 | $controller('widgetsList', {$scope: $rootScope}); 79 | $rootScope.widgets = []; 80 | $rootScope.addWidget('aaa', 1); 81 | expect($rootScope.widgets.length).toBe(1); 82 | })); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /test/spec/controllers/main.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing widget controllers', function () { 4 | 5 | it('should have 5 awesome things', function () { 6 | module('widget1'); 7 | inject(function ($controller, $rootScope) { 8 | $controller('Widget1Ctrl', {$scope: $rootScope}); 9 | expect($rootScope.awesomeThings.length).toBe(5); 10 | }); 11 | }); 12 | 13 | it('should have 5 awesome bad things', function () { 14 | module('widget2'); 15 | inject(function ($controller, $rootScope) { 16 | $controller('Widget2Ctrl', {$scope: $rootScope}); 17 | expect($rootScope.awesomeThings.length).toBe(5); 18 | }); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /test/spec/demo.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing demo app', function () { 4 | 5 | beforeEach(function () { 6 | module('angularWidgetApp'); 7 | }); 8 | 9 | it('should generate manifest', inject(function (widgets) { 10 | expect(widgets.getWidgetManifest('widget1')).toEqual({ 11 | module: 'widget1', 12 | html: 'views/widget1.html', 13 | files: ['scripts/controllers/widget1.js', 'styles/widget1.css'] 14 | }); 15 | 16 | expect(widgets.getWidgetManifest('widget2')).toEqual({ 17 | module: 'widget2', 18 | html: 'views/widget2.html', 19 | files: ['scripts/controllers/widget2.js', 'styles/widget2.css'] 20 | }); 21 | })); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /test/spec/directives/ng-widget.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing ngWidget directive', function () { 4 | var widgetInjector, widgetConfig, widgetIsLoading, widgets, spies, element; 5 | 6 | beforeEach(function () { 7 | widgetIsLoading = false; 8 | angular.module('dummyWidget', []).run(function (widgetConfig) { 9 | if (widgetIsLoading) { 10 | widgetConfig.exportProperties({loading: true}); 11 | } else { 12 | widgetConfig.exportProperties({prop: 123}); 13 | } 14 | }); 15 | 16 | module('angularWidgetInternal'); 17 | 18 | module({ 19 | fileLoader: { 20 | loadFiles: jasmine.createSpy() 21 | } 22 | }, function (widgetsProvider) { 23 | widgetsProvider.setManifestGenerator(function ($q) { 24 | return function manifestGenerator(name) { 25 | if (name === 'promise') { 26 | return $q.when(manifestGenerator('dummy')); 27 | } 28 | return { 29 | module: name + 'Widget', 30 | html: 'views/' + name + '-widget.html', 31 | files: [ 32 | 'scripts/' + name + '-widget.js', 33 | 'styles/' + name + '-widget.css' 34 | ] 35 | }; 36 | }; 37 | }); 38 | 39 | widgetsProvider.addServiceToShare('$location'); 40 | widgetsProvider.addEventToForward('$locationChangeStart'); 41 | widgetsProvider.addEventToForward('someEventToForward'); 42 | }); 43 | }); 44 | 45 | function downloadWidgetSuccess(name) { 46 | inject(function ($q, $httpBackend, fileLoader) { 47 | $httpBackend.expectGET('views/' + (name || 'dummy') + '-widget.html') 48 | .respond('
'); 49 | fileLoader.loadFiles.and.returnValue($q.when()); 50 | }); 51 | } 52 | 53 | function compileWidget(scope, delay) { 54 | inject(function ($compile, $rootScope, _widgets_) { 55 | widgets = _widgets_; 56 | spyOn(widgets, 'registerWidget').and.callThrough(); 57 | spyOn(widgets, 'unregisterWidget').and.callThrough(); 58 | 59 | scope = scope || $rootScope; 60 | scope.widget = scope.widget || 'dummy'; 61 | element = $compile('')(scope); 64 | 65 | spies = jasmine.createSpyObj('spies', ['exportPropertiesUpdated', 'widgetLoaded', 'widgetError']); 66 | $rootScope.$on('exportPropertiesUpdated', spies.exportPropertiesUpdated); 67 | $rootScope.$on('widgetLoaded', spies.widgetLoaded); 68 | $rootScope.$on('widgetError', spies.widgetError); 69 | }); 70 | } 71 | 72 | function flushDownload() { 73 | inject(function ($httpBackend) { 74 | $httpBackend.flush(); 75 | widgetInjector = element.find('div').injector(); 76 | widgetConfig = widgetInjector && widgetInjector.get('widgetConfig'); 77 | }); 78 | } 79 | 80 | it('should load the widget into the element with different injector', function () { 81 | downloadWidgetSuccess(); 82 | compileWidget(); 83 | 84 | flushDownload(); 85 | 86 | expect(element.find('div').html()).toBe('123'); 87 | expect(element.injector()).not.toBe(widgetInjector); 88 | expect(widgets.registerWidget).toHaveBeenCalledWith(widgetInjector); 89 | 90 | expect(widgetConfig.getOptions()).toEqual({}); 91 | }); 92 | 93 | it('should allow manifest generator to return a promise', inject(function ($rootScope) { 94 | $rootScope.widget = 'promise'; 95 | downloadWidgetSuccess(); 96 | compileWidget(); 97 | flushDownload(); 98 | expect(element.find('div').html()).toBe('123'); 99 | })); 100 | 101 | it('should respect delay attribute', inject(function ($timeout) { 102 | downloadWidgetSuccess(); 103 | compileWidget(undefined, 1000); 104 | 105 | flushDownload(); 106 | expect(widgetInjector).toBeUndefined(); 107 | 108 | $timeout.flush(1000); 109 | widgetInjector = element.find('div').injector(); 110 | expect(widgetInjector).toBeDefined(); 111 | })); 112 | 113 | it('should respect delay attribute for errors too', inject(function ($httpBackend, $timeout) { 114 | $httpBackend.expectGET('views/dummy-widget.html').respond(500, 'wtf'); 115 | compileWidget(undefined, 1000); 116 | 117 | flushDownload(); 118 | expect(spies.widgetError).not.toHaveBeenCalled(); 119 | 120 | $timeout.flush(1000); 121 | expect(spies.widgetError).toHaveBeenCalled(); 122 | })); 123 | 124 | it('should share services that were declared as shared', inject(function ($rootScope, $location) { 125 | downloadWidgetSuccess(); 126 | compileWidget(); 127 | flushDownload(); 128 | 129 | expect(widgetInjector.get('$rootScope')).not.toBe($rootScope); 130 | expect(widgetInjector.get('$location')).toBe($location); 131 | })); 132 | 133 | it('should forward events that were declared as forwarded', inject(function ($rootScope) { 134 | downloadWidgetSuccess(); 135 | compileWidget(); 136 | flushDownload(); 137 | 138 | var widgetScope = widgetInjector.get('$rootScope'); 139 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 140 | var eventSpy2 = jasmine.createSpy('$locationChangeSuccess'); 141 | var watchSpy = jasmine.createSpy('watchSpy'); 142 | 143 | widgetScope.$on('$locationChangeStart', eventSpy); 144 | widgetScope.$on('$locationChangeSuccess', eventSpy2); 145 | widgetScope.$watch(watchSpy, angular.noop); 146 | 147 | $rootScope.$broadcast('$locationChangeStart', 1, 2, 3); 148 | $rootScope.$broadcast('$locationChangeSuccess', 1, 2, 3); 149 | 150 | expect(eventSpy).toHaveBeenCalledWith(jasmine.any(Object), 1, 2, 3); 151 | expect(eventSpy2).not.toHaveBeenCalled(); 152 | expect(watchSpy).toHaveBeenCalled(); 153 | })); 154 | 155 | it('should call forward events handler while running main scope\'s $digest cycle', inject(function ($rootScope) { 156 | downloadWidgetSuccess(); 157 | compileWidget(); 158 | flushDownload(); 159 | 160 | var widgetScope = widgetInjector.get('$rootScope'); 161 | var event1Handler = jasmine.createSpy('$locationChangeStart'); 162 | var event2Handler = jasmine.createSpy('someEventToForward'); 163 | 164 | $rootScope.$on('someEventToForward', event2Handler); 165 | $rootScope.$on('$locationChangeStart', event1Handler.and.callFake(function () { 166 | widgetScope.$emit('someEventToForward'); 167 | })); 168 | 169 | widgetScope.$emit('$locationChangeStart'); 170 | 171 | expect(event1Handler).toHaveBeenCalledWith(jasmine.any(Object)); 172 | expect(event2Handler).toHaveBeenCalled(); 173 | })); 174 | 175 | it('should emit events that were declared as forwarded', inject(function ($rootScope) { 176 | downloadWidgetSuccess(); 177 | compileWidget(); 178 | flushDownload(); 179 | 180 | var widgetScope = widgetInjector.get('$rootScope'); 181 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 182 | var eventSpy2 = jasmine.createSpy('$locationChangeSuccess'); 183 | var watchSpy = jasmine.createSpy('watchSpy'); 184 | 185 | $rootScope.$on('$locationChangeStart', eventSpy); 186 | $rootScope.$on('$locationChangeSuccess', eventSpy2); 187 | $rootScope.$watch(watchSpy, angular.noop); 188 | 189 | widgetScope.$emit('$locationChangeStart', 1, 2, 3); 190 | widgetScope.$emit('$locationChangeSuccess', 1, 2, 3); 191 | 192 | expect(eventSpy).toHaveBeenCalledWith(jasmine.any(Object), 1, 2, 3); 193 | expect(eventSpy2).not.toHaveBeenCalled(); 194 | expect(watchSpy).toHaveBeenCalled(); 195 | })); 196 | 197 | it('should not forward events that were broadcasted on widget scope', inject(function ($rootScope) { 198 | downloadWidgetSuccess(); 199 | compileWidget(); 200 | flushDownload(); 201 | 202 | var widgetScope = widgetInjector.get('$rootScope'); 203 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 204 | 205 | $rootScope.$on('$locationChangeStart', eventSpy); 206 | widgetScope.$broadcast('$locationChangeStart', 1, 2, 3); 207 | 208 | expect(eventSpy).not.toHaveBeenCalled(); 209 | })); 210 | 211 | it('should not forward events that were emitted on root scope', inject(function ($rootScope) { 212 | downloadWidgetSuccess(); 213 | compileWidget(); 214 | flushDownload(); 215 | 216 | var widgetScope = widgetInjector.get('$rootScope'); 217 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 218 | 219 | widgetScope.$on('$locationChangeStart', eventSpy); 220 | $rootScope.$emit('$locationChangeStart', 1, 2, 3); 221 | 222 | expect(eventSpy).not.toHaveBeenCalled(); 223 | })); 224 | 225 | it('should allow widget to prevent default', inject(function ($rootScope) { 226 | downloadWidgetSuccess(); 227 | compileWidget(); 228 | flushDownload(); 229 | 230 | var widgetScope = widgetInjector.get('$rootScope'); 231 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 232 | 233 | widgetScope.$on('$locationChangeStart', eventSpy); 234 | expect($rootScope.$broadcast('$locationChangeStart', 1, 2, 3).defaultPrevented).toBe(false); 235 | 236 | eventSpy.and.callFake(function (e) { 237 | e.preventDefault(); 238 | }); 239 | expect($rootScope.$broadcast('$locationChangeStart', 1, 2, 3).defaultPrevented).toBe(true); 240 | })); 241 | 242 | it('should allow hosting app to prevent default', inject(function ($rootScope) { 243 | downloadWidgetSuccess(); 244 | compileWidget(); 245 | flushDownload(); 246 | 247 | var widgetScope = widgetInjector.get('$rootScope'); 248 | var eventSpy = jasmine.createSpy('$locationChangeStart'); 249 | 250 | $rootScope.$on('$locationChangeStart', eventSpy); 251 | expect(widgetScope.$emit('$locationChangeStart', 1, 2, 3).defaultPrevented).toBe(false); 252 | 253 | eventSpy.and.callFake(function (e) { 254 | e.preventDefault(); 255 | }); 256 | expect(widgetScope.$emit('$locationChangeStart', 1, 2, 3).defaultPrevented).toBe(true); 257 | })); 258 | 259 | it('should emit events', function () { 260 | downloadWidgetSuccess(); 261 | compileWidget(); 262 | 263 | flushDownload(); 264 | 265 | expect(spies.exportPropertiesUpdated).toHaveBeenCalledWith(jasmine.any(Object), {prop: 123}); 266 | expect(spies.widgetLoaded).toHaveBeenCalled(); 267 | expect(spies.widgetError).not.toHaveBeenCalled(); 268 | 269 | widgetConfig.reportError(); 270 | expect(spies.widgetError).toHaveBeenCalled(); 271 | 272 | widgetConfig.exportProperties({xxx: 456}); 273 | expect(spies.exportPropertiesUpdated).toHaveBeenCalledWith(jasmine.any(Object), {prop: 123, xxx: 456}); 274 | 275 | widgetConfig.exportProperties({prop: 456}); 276 | expect(spies.exportPropertiesUpdated).toHaveBeenCalledWith(jasmine.any(Object), {prop: 456, xxx: 456}); 277 | }); 278 | 279 | it('should emit loading event after loading complete', function () { 280 | downloadWidgetSuccess(); 281 | compileWidget(); 282 | 283 | widgetIsLoading = true; 284 | flushDownload(); 285 | 286 | expect(spies.widgetLoaded).not.toHaveBeenCalled(); 287 | widgetConfig.exportProperties({xxx: 123}); 288 | expect(spies.widgetLoaded).not.toHaveBeenCalled(); 289 | widgetConfig.exportProperties({loading: false}); 290 | expect(spies.widgetLoaded).toHaveBeenCalled(); 291 | }); 292 | 293 | it('should update options', inject(function ($rootScope) { 294 | downloadWidgetSuccess(); 295 | compileWidget(); 296 | 297 | $rootScope.options = {opt: 123}; 298 | flushDownload(); 299 | 300 | expect(widgetConfig.getOptions()).toEqual({opt: 123}); 301 | $rootScope.$apply(function () { 302 | $rootScope.options.xxx = 456; 303 | }); 304 | expect(widgetConfig.getOptions()).toEqual({opt: 123, xxx: 456}); 305 | })); 306 | 307 | it('should have correct options in run block', inject(function ($rootScope) { 308 | angular.module('dummyWidget').run(function (widgetConfig) { 309 | expect(widgetConfig.getOptions()).toEqual({opt: 123}); 310 | }); 311 | 312 | downloadWidgetSuccess(); 313 | compileWidget(); 314 | 315 | $rootScope.options = {opt: 123}; 316 | flushDownload(); 317 | })); 318 | 319 | it('should load the correct files', inject(function ($rootScope, fileLoader) { 320 | downloadWidgetSuccess(); 321 | compileWidget(); 322 | 323 | $rootScope.$digest(); 324 | 325 | expect(fileLoader.loadFiles).toHaveBeenCalledWith(['scripts/dummy-widget.js', 'styles/dummy-widget.css']); 326 | expect(fileLoader.loadFiles.calls.count()).toBe(1); 327 | })); 328 | 329 | it('should not load the files if module exists', inject(function (fileLoader) { 330 | angular.module('dummyWidget').requires.push('angularWidget'); 331 | downloadWidgetSuccess(); 332 | compileWidget(); 333 | 334 | flushDownload(); 335 | 336 | expect(fileLoader.loadFiles).not.toHaveBeenCalled(); 337 | expect(element.find('div').html()).toBe('123'); 338 | })); 339 | 340 | it('should report error if html fails to load', inject(function ($httpBackend) { 341 | $httpBackend.expectGET('views/dummy-widget.html').respond(500, 'wtf'); 342 | compileWidget(); 343 | 344 | flushDownload(); 345 | 346 | expect(spies.widgetError).toHaveBeenCalled(); 347 | })); 348 | 349 | it('should report error if html fails to parse', inject(function ($httpBackend) { 350 | $httpBackend.expectGET('views/dummy-widget.html').respond('<><<'); 351 | compileWidget(); 352 | 353 | flushDownload(); 354 | 355 | expect(spies.widgetError).toHaveBeenCalled(); 356 | })); 357 | 358 | it('should report error if tag fileLoader fails to load', inject(function ($q, fileLoader) { 359 | downloadWidgetSuccess(); 360 | compileWidget(); 361 | 362 | fileLoader.loadFiles.and.returnValue($q.reject()); 363 | flushDownload(); 364 | 365 | expect(spies.widgetError).toHaveBeenCalled(); 366 | })); 367 | 368 | it('should not handle error if src was already changed', inject(function ($rootScope, $timeout, $q, fileLoader) { 369 | downloadWidgetSuccess('stam'); 370 | compileWidget(); 371 | 372 | $rootScope.widget = 'stam'; 373 | $rootScope.$digest(); 374 | fileLoader.loadFiles.and.returnValue($q.reject()); 375 | 376 | $rootScope.widget = 'dummy'; 377 | downloadWidgetSuccess(); 378 | flushDownload(); 379 | 380 | expect(spies.widgetError).not.toHaveBeenCalled(); 381 | expect(spies.widgetLoaded).toHaveBeenCalledWith(jasmine.any(Object), 'dummy'); 382 | })); 383 | 384 | it('should not handle success if src was already changed', inject(function ($rootScope, $timeout, $q, $httpBackend, fileLoader) { 385 | downloadWidgetSuccess('stam'); 386 | compileWidget(); 387 | 388 | $rootScope.widget = 'stam'; 389 | $httpBackend.flush(); 390 | $timeout.flush(500); 391 | 392 | $rootScope.widget = 'dummy'; 393 | downloadWidgetSuccess(); 394 | fileLoader.loadFiles.and.returnValue($q.reject()); 395 | flushDownload(); 396 | 397 | expect(spies.widgetError).toHaveBeenCalled(); 398 | expect(spies.widgetLoaded).not.toHaveBeenCalled(); 399 | })); 400 | 401 | it('should empty html when src is empty', inject(function ($rootScope) { 402 | compileWidget(); 403 | $rootScope.widget = ''; 404 | 405 | element.html('123'); 406 | $rootScope.$digest(); 407 | expect(element.html()).toBe(''); 408 | })); 409 | 410 | it('should empty html & unregister when src is changed', inject(function ($rootScope, widgets) { 411 | downloadWidgetSuccess(); 412 | compileWidget(); 413 | 414 | flushDownload(); 415 | 416 | $rootScope.widget = 'stam'; 417 | downloadWidgetSuccess('stam'); 418 | 419 | expect(element.html()).not.toBe(''); 420 | $rootScope.$digest(); 421 | expect(widgets.unregisterWidget).toHaveBeenCalledWith(widgetInjector); 422 | expect(element.html()).toBe(''); 423 | })); 424 | 425 | it('should unregister when scope is destroyed', inject(function ($rootScope, widgets) { 426 | downloadWidgetSuccess(); 427 | compileWidget(); 428 | 429 | flushDownload(); 430 | 431 | element.scope().$destroy(); 432 | expect(widgets.unregisterWidget).toHaveBeenCalledWith(widgetInjector); 433 | })); 434 | 435 | it('should not load the files if in module exists', inject(function ($httpBackend, $rootScope, $compile, fileLoader) { 436 | angular.module('dummyWidget').requires.push('angularWidget'); 437 | $httpBackend.expectGET('views/dummy-widget.html').respond('
'); 438 | element = $compile('')($rootScope); 439 | flushDownload(); 440 | 441 | expect(fileLoader.loadFiles).not.toHaveBeenCalled(); 442 | expect(element.find('div').html()).toBe('123'); 443 | })); 444 | 445 | it('should add angularWidget to the module requirements', function () { 446 | downloadWidgetSuccess(); 447 | compileWidget(); 448 | flushDownload(); 449 | expect(widgetInjector.get('ngWidgetDirective')).toBeTruthy(); 450 | }); 451 | 452 | it('should run custom config block when bootstraping', inject(function (widgets) { 453 | var hook = widgets.getWidgetManifest; 454 | widgets.getWidgetManifest = function () { 455 | return angular.extend(hook.apply(widgets, arguments), { 456 | config: [function ($provide) { 457 | $provide.value('shahata', 123); 458 | }] 459 | }); 460 | }; 461 | downloadWidgetSuccess(); 462 | compileWidget(); 463 | flushDownload(); 464 | expect(widgetInjector.get('shahata')).toBe(123); 465 | })); 466 | 467 | it('should block protractor while widget is still downloading', function (done) { 468 | var element; 469 | var widgetContent = 'some text'; 470 | var protractor = jasmine.createSpy('protractor').and.callFake(function () { 471 | expect(element.text()).toBe(widgetContent); 472 | done(); 473 | }); 474 | 475 | var $injector = createNewInjectorAndConfigureWidget(['widget1.html.js', 'widget2.html.js'], widgetContent); 476 | 477 | $injector.invoke(function ($compile, $browser, $rootScope) { 478 | element = $compile('')($rootScope); 479 | $browser.notifyWhenNoOutstandingRequests(protractor); 480 | }); 481 | }); 482 | 483 | it('should release protractor in case the widget had errors while loading', function (done) { 484 | var element; 485 | var protractor = jasmine.createSpy('protractor').and.callFake(done); 486 | 487 | var $injector = createNewInjectorAndConfigureWidget(['some-non-existing.html.js', 'widget1.html.js']); 488 | 489 | $injector.invoke(function ($compile, $browser, $rootScope) { 490 | element = $compile('')($rootScope); 491 | $browser.notifyWhenNoOutstandingRequests(protractor); 492 | }); 493 | }); 494 | 495 | function createNewInjectorAndConfigureWidget(files, widgetContent) { 496 | //must create different injector since injector created by inject() includes 497 | //ngMock, which replaces $browser.notifyWhenNoOutstandingRequests() with 498 | //implementation which immediately invokes callback no matter what 499 | files = files.map(function (fileName) { 500 | return '/base/app/views/' + fileName; 501 | }); 502 | return angular.injector(['ng', 'angularWidget', function ($provide) { 503 | $provide.value('$rootElement', angular.element('
')); 504 | }, function (widgetsProvider) { 505 | widgetsProvider.setManifestGenerator(function ($templateCache) { 506 | $templateCache.put('views/dummy-widget.html', '
' + widgetContent + '
'); 507 | return function manifestGenerator() { 508 | return { 509 | module: 'dummyWidget', 510 | html: 'views/dummy-widget.html', 511 | files: [files] 512 | }; 513 | }; 514 | }); 515 | }]); 516 | } 517 | 518 | }); 519 | -------------------------------------------------------------------------------- /test/spec/e2e/scenarios.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('angularWidgetApp', function () { 4 | 5 | beforeEach(function () { 6 | // browser.addMockModule('angularWidgetMocks', function () {}); 7 | }); 8 | 9 | afterEach(function () { 10 | // browser.removeMockModule(); 11 | }); 12 | 13 | it('should load successfully', function () { 14 | browser.get('/'); 15 | // expect(element(by.css('h3')).getText()).toEqual('Enjoy coding! - Yeoman'); 16 | }); 17 | 18 | }); 19 | -------------------------------------------------------------------------------- /test/spec/services/file-loader.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Service: fileLoader', function () { 4 | var pendingFiles, $rootScope; 5 | 6 | beforeEach(function () { 7 | pendingFiles = []; 8 | 9 | module('angularWidgetInternal'); 10 | module({ 11 | tagAppender: jasmine.createSpy('tagAppender').and.callFake(function () { 12 | var defer; 13 | inject(function ($q) { 14 | defer = $q.defer(); 15 | }); 16 | pendingFiles.push(defer); 17 | return defer.promise; 18 | }) 19 | }); 20 | }); 21 | 22 | beforeEach(inject(function (_$rootScope_) { 23 | $rootScope = _$rootScope_; 24 | })); 25 | 26 | function flushPromises(count) { 27 | for (var i = 0; i < count; i++) { 28 | pendingFiles.shift().resolve(); 29 | } 30 | $rootScope.$digest(); 31 | } 32 | 33 | function flushCalls(filenames) { 34 | inject(function (tagAppender) { 35 | filenames.forEach(function (filename) { 36 | expect(tagAppender).toHaveBeenCalledWith(filename, jasmine.any(String)); 37 | }); 38 | expect(tagAppender.calls.count()).toBe(filenames.length); 39 | tagAppender.calls.reset(); 40 | flushPromises(filenames.length); 41 | }); 42 | } 43 | 44 | function rejectPromise() { 45 | pendingFiles.shift().reject(); 46 | $rootScope.$digest(); 47 | } 48 | 49 | function loadFiles(filenames) { 50 | var promise; 51 | inject(function (fileLoader) { 52 | promise = fileLoader.loadFiles(filenames); 53 | $rootScope.$digest(); 54 | }); 55 | return promise; 56 | } 57 | 58 | it('should extract extension from loaded file', inject(function (tagAppender) { 59 | loadFiles(['a.js']); 60 | expect(tagAppender).toHaveBeenCalledWith('a.js', 'js'); 61 | })); 62 | 63 | it('should load files in parallel', function () { 64 | var spy = jasmine.createSpy('filesLoaded'); 65 | loadFiles(['a.js', 'b.js']).then(spy); 66 | flushCalls(['a.js', 'b.js']); 67 | expect(spy).toHaveBeenCalled(); 68 | }); 69 | 70 | it('should fail if one of the files failed to load', function () { 71 | var spy = jasmine.createSpy('filesFailed'); 72 | loadFiles(['a.js', 'b.js']).catch(spy); 73 | rejectPromise(); 74 | expect(spy).toHaveBeenCalled(); 75 | }); 76 | 77 | it('should load nested array serially', function () { 78 | var spy = jasmine.createSpy('filesLoaded'); 79 | loadFiles([['a.js', 'b.js']]).then(spy); 80 | flushCalls(['a.js']); 81 | flushCalls(['b.js']); 82 | expect(spy).toHaveBeenCalled(); 83 | }); 84 | 85 | it('should fail if one of the sequential files failed to load', function () { 86 | var spy = jasmine.createSpy('filesFailed'); 87 | loadFiles([['a.js', 'b.js']]).catch(spy); 88 | rejectPromise(); 89 | expect(spy).toHaveBeenCalled(); 90 | }); 91 | 92 | it('should handle files and arrays correctly', function () { 93 | var spy = jasmine.createSpy('filesFailed'); 94 | loadFiles(['a.js', 'b.js', ['c.js', 'd.js'], ['c.css', 'd.css']]).then(spy); 95 | flushCalls(['a.js', 'b.js', 'c.js', 'c.css']); 96 | flushCalls(['d.js', 'd.css']); 97 | expect(spy).toHaveBeenCalled(); 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /test/spec/services/tag-appender.spec.js: -------------------------------------------------------------------------------- 1 | /* global requirejs */ 2 | 'use strict'; 3 | 4 | describe('Unit testing tagAppender service', function () { 5 | var headElement; 6 | 7 | beforeEach(function () { 8 | module('angularWidgetInternal'); 9 | module({ 10 | headElement: headElement = jasmine.createSpyObj('headElement', ['appendChild']), 11 | requirejs: undefined 12 | }); 13 | }); 14 | 15 | describe('Loading with requirejs when available and headElement is head', function () { 16 | 17 | var moduleName = 'base/test/mock/mock-lazyloaded-file.js'; 18 | 19 | beforeEach(function () { 20 | //Restoring the head element makes requirejs come into action 21 | module({ 22 | headElement: window.document.getElementsByTagName('head')[0], 23 | requirejs: requirejs 24 | }); 25 | }); 26 | 27 | beforeEach(function () { 28 | inject(function ($window) { 29 | delete $window.lazyLoadingWorking; 30 | }); 31 | }); 32 | 33 | afterEach(inject(function ($window) { 34 | $window.requirejs.undef(moduleName); 35 | })); 36 | 37 | it('should load the javascript files', function (done) { 38 | inject(function (tagAppender, $window) { 39 | expect($window.lazyLoadingWorking).toBeFalsy(); 40 | tagAppender(moduleName, 'js').then(function () { 41 | expect($window.lazyLoadingWorking).toBe(true); 42 | done(); 43 | }); 44 | }); 45 | }); 46 | 47 | it('should fail when file doesn\'t exist', function (done) { 48 | inject(function (tagAppender) { 49 | tagAppender('base/test/mock/non-existing-file.js', 'js').catch(done); 50 | }); 51 | }); 52 | 53 | it('should not fail when same file loads two times', function (done) { 54 | inject (function (tagAppender, $window) { 55 | expect($window.lazyLoadingWorking).toBeFalsy(); 56 | tagAppender(moduleName, 'js').then(function () { 57 | tagAppender(moduleName, 'js').then(function () { 58 | expect($window.lazyLoadingWorking).toBe(true); 59 | done(); 60 | }); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | it('should append script tag when js file is added', inject(function (tagAppender) { 67 | tagAppender('dummy.js', 'js'); 68 | expect(headElement.appendChild.calls.count()).toBe(1); 69 | expect(headElement.appendChild.calls.first().args[0].outerHTML) 70 | .toBe(''); 71 | })); 72 | 73 | it('should load the file only once in case the same file is loaded multiple times simultaneously ', inject (function (tagAppender) { 74 | tagAppender('dummy.js', 'js'); 75 | tagAppender('dummy.js', 'js'); 76 | expect(headElement.appendChild.calls.count()).toBe(1); 77 | })); 78 | 79 | it('should re try to download the file in case first attempt failed', inject (function (tagAppender) { 80 | var secondAttemptSuccess = jasmine.createSpy('secondAttemptSuccess'); 81 | 82 | tagAppender('dummy.js', 'js').catch(function () { 83 | tagAppender('dummy.js', 'js').then(secondAttemptSuccess); 84 | simulateLoadSuccessOnCall(1); 85 | }); 86 | 87 | simulateLoadErrorOnCall(0); 88 | expect(secondAttemptSuccess).toHaveBeenCalled(); 89 | })); 90 | 91 | it('should append link tag when css file is added', inject(function (tagAppender) { 92 | tagAppender('dummy.css', 'css'); 93 | expect(headElement.appendChild.calls.count()).toBe(1); 94 | expect(headElement.appendChild.calls.first().args[0].outerHTML) 95 | .toBe(''); 96 | })); 97 | 98 | it('should poll stylesheets in safari 5', function () { 99 | module(function ($provide) { 100 | $provide.value('navigator', {userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.57.2 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2'}); 101 | $provide.value('$document', [{styleSheets: []}]); 102 | }); 103 | inject(function (tagAppender, $document, $interval, $rootScope) { 104 | var success = jasmine.createSpy('success'); 105 | tagAppender('dummy.css', 'css').then(success); 106 | 107 | $document[0].styleSheets.push({href: 'not-dummy.css'}); 108 | $interval.flush(50); 109 | $rootScope.$digest(); 110 | expect(success).not.toHaveBeenCalled(); 111 | 112 | $document[0].styleSheets.push({href: 'dummy.css'}); 113 | $interval.flush(50); 114 | $rootScope.$digest(); 115 | expect(success).toHaveBeenCalled(); 116 | }); 117 | }); 118 | 119 | it('should poll stylesheets in safari 5 ignoring protocol', function () { 120 | module(function ($provide) { 121 | $provide.value('navigator', {userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.57.2 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2'}); 122 | $provide.value('$document', [{styleSheets: []}]); 123 | }); 124 | inject(function (tagAppender, $document, $interval, $rootScope) { 125 | var success = jasmine.createSpy('success'); 126 | tagAppender('//static.wix.com/dummy.css', 'css').then(success); 127 | 128 | $document[0].styleSheets.push({href: 'http://static.wix.com/not-dummy.css'}); 129 | $document[0].styleSheets.push({href: null}); 130 | $interval.flush(50); 131 | $rootScope.$digest(); 132 | expect(success).not.toHaveBeenCalled(); 133 | 134 | $document[0].styleSheets.push({href: 'http://static.wix.com/dummy.css'}); 135 | $interval.flush(50); 136 | $rootScope.$digest(); 137 | expect(success).toHaveBeenCalled(); 138 | }); 139 | }); 140 | 141 | it('should fail stylesheets polling after timeout', function () { 142 | module(function ($provide) { 143 | $provide.value('navigator', {userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.57.2 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2'}); 144 | $provide.value('$document', [{styleSheets: []}]); 145 | }); 146 | inject(function (tagAppender, $document, $interval, $rootScope) { 147 | var error = jasmine.createSpy('error'); 148 | tagAppender('dummy.css', 'css').catch(error); 149 | $interval.flush(1000); 150 | $rootScope.$digest(); 151 | expect(error).toHaveBeenCalled(); 152 | }); 153 | }); 154 | 155 | it('should resolve the promise when onload is invoked', inject(function (tagAppender) { 156 | var success = jasmine.createSpy('success'); 157 | tagAppender('dummy.js', 'js').then(success); 158 | headElement.appendChild.calls.first().args[0].onload(); 159 | expect(success).toHaveBeenCalled(); 160 | })); 161 | 162 | it('should block protractor while script is still downloading', function () { 163 | //must create different injector since injector created by inject() includes 164 | //ngMock, which replaces $browser.notifyWhenNoOutstandingRequests() with 165 | //implementation which immediately invokes callback no matter what 166 | var $injector = angular.injector(['ng', 'angularWidgetInternal', function ($provide) { 167 | $provide.value('headElement', headElement); 168 | $provide.value('requirejs', undefined); 169 | }]); 170 | 171 | $injector.invoke(function (tagAppender, $browser) { 172 | var protractor = jasmine.createSpy('protractor'); 173 | tagAppender('dummy.js', 'js'); 174 | $browser.notifyWhenNoOutstandingRequests(protractor); 175 | expect(protractor).not.toHaveBeenCalled(); 176 | headElement.appendChild.calls.first().args[0].onload(); 177 | expect(protractor).toHaveBeenCalled(); 178 | }); 179 | }); 180 | 181 | it('should reject the promise when onerror is invoked', inject(function (tagAppender) { 182 | var error = jasmine.createSpy('error'); 183 | tagAppender('dummy.js', 'js').catch(error); 184 | headElement.appendChild.calls.first().args[0].onerror(); 185 | expect(error).toHaveBeenCalled(); 186 | })); 187 | 188 | it('should download each file only once', inject(function (tagAppender, $rootScope) { 189 | tagAppender('dummy.js', 'js'); 190 | headElement.appendChild.calls.first().args[0].onload(); 191 | 192 | var success = jasmine.createSpy('success'); 193 | tagAppender('dummy.js', 'js').then(success); 194 | $rootScope.$digest(); 195 | expect(success).toHaveBeenCalled(); 196 | })); 197 | 198 | it('should be compatible with IE readyState', inject(function (tagAppender) { 199 | var success = jasmine.createSpy('success'); 200 | tagAppender('dummy.js', 'js').then(success); 201 | var callLater = headElement.appendChild.calls.first().args[0].onload; 202 | 203 | headElement.appendChild.calls.first().args[0].readyState = 'loading'; 204 | headElement.appendChild.calls.first().args[0].onreadystatechange(); 205 | expect(success).not.toHaveBeenCalled(); 206 | 207 | headElement.appendChild.calls.first().args[0].readyState = 'loaded'; 208 | headElement.appendChild.calls.first().args[0].onreadystatechange(); 209 | expect(headElement.appendChild.calls.first().args[0].onreadystatechange).toBe(null); 210 | expect(success).toHaveBeenCalled(); 211 | 212 | callLater(); 213 | expect(success.calls.count()).toBe(1); 214 | })); 215 | 216 | function simulateLoadSuccessOnCall(callIndex) { 217 | headElement.appendChild.calls.argsFor(callIndex)[0].onload(); 218 | } 219 | 220 | function simulateLoadErrorOnCall(callIndex) { 221 | headElement.appendChild.calls.argsFor(callIndex)[0].onerror(); 222 | } 223 | 224 | }); 225 | -------------------------------------------------------------------------------- /test/spec/services/widget-config.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing widgetConfig service', function () { 4 | 5 | beforeEach(function () { 6 | module('angularWidgetInternal'); 7 | }); 8 | 9 | describe('with parent injector scope', function () { 10 | var log; 11 | 12 | beforeEach(function () { 13 | log = ''; 14 | module(function (widgetConfigProvider) { 15 | widgetConfigProvider.setParentInjectorScope({ 16 | $root: {}, 17 | $apply: function (fn) { 18 | log += '$apply['; 19 | fn(); 20 | log += ']'; 21 | }, 22 | $emit: function (name, arg) { 23 | log += '$emit(' + name + ':' + JSON.stringify(arg) + ')'; 24 | return {}; 25 | }, 26 | $on: angular.noop 27 | }); 28 | }); 29 | }); 30 | 31 | it('should export properties', inject(function (widgetConfig) { 32 | var props = widgetConfig.exportProperties(); 33 | expect(widgetConfig.exportProperties()).toEqual({}); 34 | expect(log).toBe(''); 35 | expect(widgetConfig.exportProperties({abc: 123})).toEqual({abc: 123}); 36 | expect(log).toBe('$apply[$emit(exportPropertiesUpdated:{"abc":123})]'); 37 | log = ''; 38 | expect(widgetConfig.exportProperties({abc: 456})).toEqual({abc: 456}); 39 | expect(log).toBe('$apply[$emit(exportPropertiesUpdated:{"abc":456})]'); 40 | log = ''; 41 | expect(widgetConfig.exportProperties()).toBe(props); 42 | expect(log).toBe(''); 43 | })); 44 | 45 | it('should report error', inject(function (widgetConfig, $log) { 46 | widgetConfig.reportError(); 47 | expect(log).toBe('$apply[$emit(widgetError:undefined)]'); 48 | expect($log.warn.logs).toEqual([]); 49 | })); 50 | }); 51 | 52 | describe('without parent injector scope', function () { 53 | it('should export properties', inject(function (widgetConfig) { 54 | var props = widgetConfig.exportProperties(); 55 | expect(widgetConfig.exportProperties()).toEqual({}); 56 | expect(widgetConfig.exportProperties({abc: 123})).toEqual({abc: 123}); 57 | expect(widgetConfig.exportProperties({abc: 456})).toEqual({abc: 456}); 58 | expect(widgetConfig.exportProperties()).toBe(props); 59 | })); 60 | 61 | it('should report error', inject(function (widgetConfig, $log) { 62 | widgetConfig.reportError(); 63 | expect($log.warn.logs).toEqual([['widget reported an error']]); 64 | })); 65 | }); 66 | 67 | describe('with destroyed parent injector scope', function () { 68 | beforeEach(function () { 69 | var destoryFn; 70 | module(function (widgetConfigProvider) { 71 | widgetConfigProvider.setParentInjectorScope({ 72 | $on: function (name, fn) { 73 | destoryFn = fn; 74 | return angular.noop; 75 | } 76 | }); 77 | }); 78 | inject(function () { 79 | destoryFn(); 80 | }); 81 | }); 82 | 83 | it('should export properties', inject(function (widgetConfig) { 84 | var props = widgetConfig.exportProperties(); 85 | expect(widgetConfig.exportProperties()).toEqual({}); 86 | expect(widgetConfig.exportProperties({abc: 123})).toEqual({abc: 123}); 87 | expect(widgetConfig.exportProperties({abc: 456})).toEqual({abc: 456}); 88 | expect(widgetConfig.exportProperties()).toBe(props); 89 | })); 90 | 91 | it('should report error', inject(function (widgetConfig, $log) { 92 | widgetConfig.reportError(); 93 | expect($log.warn.logs).toEqual([['widget reported an error']]); 94 | })); 95 | }); 96 | 97 | describe('options', function () { 98 | it('should get/set options', inject(function (widgetConfig) { 99 | var options = {a: 1}; 100 | expect(widgetConfig.getOptions()).toEqual({}); 101 | widgetConfig.setOptions(options); 102 | expect(widgetConfig.getOptions()).toEqual(options); 103 | expect(widgetConfig.getOptions()).not.toBe(options); 104 | })); 105 | 106 | it('should set options from provider', function () { 107 | var options = {a: 1}; 108 | module(function (widgetConfigProvider) { 109 | widgetConfigProvider.setOptions(options); 110 | }); 111 | inject(function (widgetConfig) { 112 | expect(widgetConfig.getOptions()).toEqual(options); 113 | expect(widgetConfig.getOptions()).not.toBe(options); 114 | }); 115 | }); 116 | 117 | it('should get options from provider', function () { 118 | var options = {a: 1}; 119 | module(function (widgetConfigProvider) { 120 | widgetConfigProvider.setOptions(options); 121 | expect(widgetConfigProvider.getOptions()).toEqual(options); 122 | expect(widgetConfigProvider.getOptions()).not.toBe(options); 123 | }); 124 | inject(angular.noop); 125 | }); 126 | }); 127 | 128 | }); 129 | -------------------------------------------------------------------------------- /test/spec/services/widgets.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Unit testing widgets service', function () { 4 | 5 | beforeEach(function () { 6 | module('angularWidgetInternal'); 7 | }); 8 | 9 | describe('manifest generators', function () { 10 | it('should set manifest generator', function () { 11 | module(function (widgetsProvider) { 12 | widgetsProvider.setManifestGenerator(function () { return angular.identity; }); 13 | }); 14 | inject(function (widgets) { 15 | expect(widgets.getWidgetManifest('shahata')).toBe('shahata'); 16 | }); 17 | }); 18 | 19 | it('should invoke generators with injector', function () { 20 | module(function (widgetsProvider) { 21 | widgetsProvider.setManifestGenerator(function ($q) { 22 | return function () { return $q; }; 23 | }); 24 | }); 25 | inject(function (widgets, $q) { 26 | expect(widgets.getWidgetManifest('shahata')).toBe($q); 27 | }); 28 | }); 29 | 30 | it('should only take last generator into account', function () { 31 | module(function (widgetsProvider) { 32 | widgetsProvider.setManifestGenerator(function () { return angular.identity; }); 33 | widgetsProvider.setManifestGenerator(function () { 34 | return function () { return 'shahar'; }; 35 | }); 36 | }); 37 | inject(function (widgets) { 38 | expect(widgets.getWidgetManifest('shahata')).toBe('shahar'); 39 | }); 40 | }); 41 | 42 | it('should take last generator into account only if it is not undefined', function () { 43 | module(function (widgetsProvider) { 44 | widgetsProvider.setManifestGenerator(function () { return angular.identity; }); 45 | widgetsProvider.setManifestGenerator(function () { 46 | return function () { return undefined; }; 47 | }); 48 | }); 49 | inject(function (widgets) { 50 | expect(widgets.getWidgetManifest('shahata')).toBe('shahata'); 51 | }); 52 | }); 53 | 54 | it('should take generator with higher priority', function () { 55 | module(function (widgetsProvider) { 56 | widgetsProvider.setManifestGenerator(function () { 57 | return function () { return {priority: 2, name: 'shahata'}; }; 58 | }); 59 | widgetsProvider.setManifestGenerator(function () { 60 | return function () { return {priority: 1, name: 'shahar'}; }; 61 | }); 62 | }); 63 | inject(function (widgets) { 64 | expect(widgets.getWidgetManifest('shahata')).toEqual({priority: 2, name: 'shahata'}); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('notify widgets', function () { 70 | it('should notify widgets when notifyWidgets is invoked', inject(function (widgets) { 71 | var digestSpy = jasmine.createSpy('$digest'); 72 | widgets.registerWidget({get: function (name) { 73 | expect(name).toBe('$rootScope'); 74 | return {$digest: digestSpy}; 75 | }}); 76 | widgets.notifyWidgets(); 77 | expect(digestSpy).toHaveBeenCalled(); 78 | })); 79 | 80 | it('should broadcast event when notifyWidgets is invoked with args', inject(function (widgets) { 81 | var digestSpy = jasmine.createSpy('$digest'); 82 | var broadcastSpy = jasmine.createSpy('$broadcastSpy').and.returnValue('shahata'); 83 | widgets.registerWidget({get: function (name) { 84 | expect(name).toBe('$rootScope'); 85 | return {$digest: digestSpy, $broadcast: broadcastSpy}; 86 | }}); 87 | expect(widgets.notifyWidgets(1, 2, 3)).toEqual(['shahata']); 88 | expect(digestSpy).toHaveBeenCalled(); 89 | expect(broadcastSpy).toHaveBeenCalledWith(1, 2, 3); 90 | })); 91 | 92 | it('should not call digest in case the caller injector is himself', inject(function (widgets, $injector) { 93 | var digestSpy = jasmine.createSpy('$digest'); 94 | widgets.registerWidget($injector); 95 | widgets.notifyWidgets(); 96 | expect(digestSpy).not.toHaveBeenCalled(); 97 | })); 98 | 99 | it('should call broadcast in case the caller injector is himself', inject(function (widgets, $injector) { 100 | var scope = $injector.get('$rootScope'); 101 | var broadcastSpy = spyOn(scope, '$broadcast'); 102 | widgets.registerWidget($injector); 103 | widgets.notifyWidgets(1, 2, 3); 104 | expect(broadcastSpy).toHaveBeenCalledWith(1, 2, 3); 105 | })); 106 | }); 107 | 108 | describe('service sharing', function () { 109 | it('should not trigger digest if arguments count is less than minimum', function () { 110 | var spy = jasmine.createSpy('hooked'); 111 | module(function (widgetsProvider) { 112 | widgetsProvider.addServiceToShare('shahata', {method: 4}); 113 | }, {shahata: {method: spy}}); 114 | inject(function (widgets, shahata, $rootScope) { 115 | shahata.method(1, 2, 3); 116 | expect(spy).toHaveBeenCalledWith(1, 2, 3); 117 | expect($rootScope.$$asyncQueue.length).toBe(0); 118 | }); 119 | }); 120 | 121 | it('should trigger digest if setter is called', function () { 122 | var spy = jasmine.createSpy('hooked'); 123 | module(function (widgetsProvider) { 124 | widgetsProvider.addServiceToShare('shahata', {method: 3}); 125 | }, {shahata: {method: spy}}); 126 | inject(function (widgets, shahata, $rootScope, $timeout) { 127 | spyOn($rootScope, '$digest'); 128 | shahata.method(1, 2, 3); 129 | $timeout.flush(); 130 | expect(spy).toHaveBeenCalledWith(1, 2, 3); 131 | expect($rootScope.$digest).toHaveBeenCalled(); 132 | }); 133 | }); 134 | 135 | it('should not trigger digest if we are during digest', function () { 136 | var spy = jasmine.createSpy('hooked'); 137 | module(function (widgetsProvider) { 138 | widgetsProvider.addServiceToShare('shahata', {method: 3}); 139 | }, {shahata: {method: spy}}); 140 | inject(function (widgets, shahata, $rootScope) { 141 | $rootScope.$apply(function () { 142 | shahata.method(1, 2, 3); 143 | }); 144 | expect(spy).toHaveBeenCalledWith(1, 2, 3); 145 | expect($rootScope.$$asyncQueue.length).toBe(0); 146 | }); 147 | }); 148 | 149 | it('should trigger digest no matter the arguments count', function () { 150 | var spy = jasmine.createSpy('hooked'); 151 | module(function (widgetsProvider) { 152 | widgetsProvider.addServiceToShare('shahata', ['method']); 153 | }, {shahata: {method: spy}}); 154 | inject(function (widgets, shahata, $rootScope, $timeout) { 155 | spyOn($rootScope, '$digest'); 156 | shahata.method(); 157 | $timeout.flush(); 158 | expect($rootScope.$digest).toHaveBeenCalled(); 159 | }); 160 | }); 161 | 162 | }); 163 | 164 | describe('widget registration', function () { 165 | it('should handle bad unregister of widget gracefully', inject(function (widgets) { 166 | try { 167 | widgets.unregisterWidget({a: 1}); 168 | } catch (e) { 169 | expect(false).toBeTruthy(); 170 | } 171 | })); 172 | 173 | it('should unregister single widget correctly', inject(function (widgets) { 174 | var digestSpy1 = jasmine.createSpy('$digest1'); 175 | var digestSpy2 = jasmine.createSpy('$digest2'); 176 | var destroySpy1 = jasmine.createSpy('$destroy1'); 177 | var destroySpy2 = jasmine.createSpy('$destroy2'); 178 | var injector1 = {get: function () { return {$digest: digestSpy1, $destroy: destroySpy1}; }}; 179 | var injector2 = {get: function () { return {$digest: digestSpy2, $destroy: destroySpy2}; }}; 180 | 181 | widgets.registerWidget(injector1); 182 | widgets.registerWidget(injector2); 183 | 184 | widgets.unregisterWidget(injector1); 185 | expect(destroySpy1).toHaveBeenCalled(); 186 | expect(destroySpy2).not.toHaveBeenCalled(); 187 | 188 | widgets.notifyWidgets(); 189 | expect(digestSpy1).not.toHaveBeenCalled(); 190 | expect(digestSpy2).toHaveBeenCalled(); 191 | })); 192 | 193 | it('should unregister all widgets correctly', inject(function (widgets) { 194 | var digestSpy1 = jasmine.createSpy('$digest1'); 195 | var digestSpy2 = jasmine.createSpy('$digest2'); 196 | var destroySpy1 = jasmine.createSpy('$destroy1'); 197 | var destroySpy2 = jasmine.createSpy('$destroy2'); 198 | var injector1 = {get: function () { return {$digest: digestSpy1, $destroy: destroySpy1}; }}; 199 | var injector2 = {get: function () { return {$digest: digestSpy2, $destroy: destroySpy2}; }}; 200 | 201 | widgets.registerWidget(injector1); 202 | widgets.registerWidget(injector2); 203 | 204 | widgets.unregisterWidget(); 205 | expect(destroySpy1).toHaveBeenCalled(); 206 | expect(destroySpy2).toHaveBeenCalled(); 207 | 208 | widgets.notifyWidgets(); 209 | expect(digestSpy1).not.toHaveBeenCalled(); 210 | expect(digestSpy2).not.toHaveBeenCalled(); 211 | })); 212 | }); 213 | 214 | }); 215 | --------------------------------------------------------------------------------