Skip to content

Commit

Permalink
buildbotService: based on Restangular
Browse files Browse the repository at this point in the history
buildbotService is a typical restangular object, but adds methods
to automatically update scope based on 'sse' events

Add unit tests:
 - EventSourceMock simulates events
 - httpMock simulates the whole backend, with help from dataspec json info.
   This will be used to unittest controllers, for E2E tests, and for backendless development

add restangular and lodash deps.
  • Loading branch information
Pierre Tardy committed Aug 3, 2013
1 parent c496548 commit c353ddd
Show file tree
Hide file tree
Showing 12 changed files with 388 additions and 30 deletions.
55 changes: 55 additions & 0 deletions master/docs/developer/www.rst
Expand Up @@ -263,7 +263,62 @@ Directives

We use angular directives as much as possible to implement reusable UI components.

Services
~~~~~~~~

BuildbotService
...............

BuildbotService is the base service for accessing to the buildbot data api.
It uses and is derivated from `restangular <https://github.com/mgonto/restangular/blob/master/README.md>`_.
Restangular offers nice semantics around nested REST endpoints. Please see restangular documentation for overview on how it works.

BuildbotService adds serveral methods to restangular objects in order to integrate it with EventSource.
The idea is to simplifify automatic update of the $scope based on events happening on a given data endpoint

.. code-block: python
# Following code will get initial data from 'api/v2/build/1/step/2'
# and register to events from 'sse/build/1/step/2'
# Up to the template to specify what to display
buildbotService.one("build", 1).one("step", 2).bind($scope)
Several methods are added to each "restangularized" objects, aside from get(), put(), delete(), etc.:

* ``.bind($scope, scope_key)``

bind the api result to the scope, automatically listening to events on this endpoint, and modifying the scope object accordingly.
scope_key defaults to the last path of the restangular object, i.e ``build/1/step/2`` binds to ``$scope.step``

* ``.unbind()``

Stop listening to events. This is automatically done when $scope is destroyed.

* ``.on(eventtype, callback)``

Listen to events for this endpoint. When bind() semantic is not useful enough, you can use this lower level api.
You need to manually call unbind() when the scope is destroyed. EventSource connection is shared between listeners on the same endpoint.

Mocks and testing utils
~~~~~~~~~~~~~~~~~~~~~~~

httpMock.coffee
...............

This modules adds ``decorateHttpBackend($httpBackend)`` to the global namespace. This function decorate the $httpBackend with additional functionality:

* ``.expectDataGET(ep, {nItems:<int or undefined>, override: <fn or undefined>})``

automatically create a GET expectation to the data api, given the data spec
Options available are:

* ``nItems``: if defined, this will generate a collection of nItems instead of single value

* ``override``: a custom function to override the resulting generated data

Example: ``$httpBackend.expectDataGET("change", {nItems:2, override: (val) -> val[1].id=4 })``
will create 2 changes, but the id of the second change will be overridden to 4

Linking with Buildbot
~~~~~~~~~~~~~~~~~~~~~
Expand Down
15 changes: 11 additions & 4 deletions www/Gruntfile.coffee
Expand Up @@ -34,9 +34,6 @@ module.exports = (grunt) ->
ext: '.js'
]
options:
# Don't include a surrounding Immediately-Invoked Function Expression (IIFE) in the compiled output.
# For more information on IIFEs, please visit http://benalman.com/news/2010/11/immediately-invoked-function-expression/
bare: true
sourceMap: true
sourceRoot : '/src'
concat:
Expand Down Expand Up @@ -321,7 +318,7 @@ module.exports = (grunt) ->
'copy:index'
]
scripts:
files: './src/scripts/**'
files: ['./src/scripts/**', './test/scripts/**']
tasks: [
'coffee:scripts'
'copy:js'
Expand Down Expand Up @@ -371,6 +368,15 @@ module.exports = (grunt) ->

grunt.loadTasks 'tasks'

grunt.registerTask 'dataspec', ->
done = @async()
grunt.util.spawn
cmd: "buildbot"
args: "dataspec -o buildbot_www_test/scripts/dataspec.js -g dataspec".split(" ")
, (error, result, code) ->
grunt.log.write result.toString()
done(!error)

# Compiles the app with non-optimized build settings, places the build artifacts in the buildbot_www directory, and runs unit tests.
# Enter the following command at the command line to execute this build task:
# grunt ci
Expand Down Expand Up @@ -407,6 +413,7 @@ module.exports = (grunt) ->
grunt.registerTask 'default', [
'clean:working'
'concat:bower_configs'
'dataspec'
'coffee:scripts'
'copy:js'
'requiregen:main'
Expand Down
1 change: 1 addition & 0 deletions www/karma.conf.js
Expand Up @@ -9,6 +9,7 @@ files = [
'./buildbot_www/scripts/libs/require.js',
'./buildbot_www/scripts/**/*.js',

'./buildbot_www_test/scripts/dataspec.js',
'./buildbot_www_test/scripts/filters/*.js',
'./buildbot_www_test/scripts/services/*.js'
];
Expand Down
2 changes: 2 additions & 0 deletions www/setup.py
Expand Up @@ -103,6 +103,8 @@
"font-awesome": "latest",
"angular": "latest",
"angular-resource": "latest",
"restangular": "latest",
"lodash": "latest",
"html5shiv": "~3.6.2",
"jquery": "~2.0.0",
"requirejs": "~2.1.5",
Expand Down
2 changes: 1 addition & 1 deletion www/src/scripts/app.coffee
@@ -1 +1 @@
angular.module 'app', ['ngResource']
angular.module 'app', ['restangular']
1 change: 1 addition & 0 deletions www/src/scripts/libs/lodash.js
1 change: 1 addition & 0 deletions www/src/scripts/libs/restangular.js
1 change: 1 addition & 0 deletions www/src/scripts/run.coffee
Expand Up @@ -14,6 +14,7 @@ angular.module('app').run

plugins_modules = []
plugins_paths = {}
@config ?= {plugins: {}}

# load plugins's css (async)
for plugin, cfg of @config.plugins
Expand Down
61 changes: 48 additions & 13 deletions www/src/scripts/services/buildbotService.coffee
@@ -1,17 +1,52 @@
angular.module('app').factory 'EventSource', ->
# turn HTML5's EventSource into a angular module for mockability
return (url)->
return new EventSource(url)
BASEURLAPI = 'api/v2/'
BASEURLSSE = 'sse/'
angular.module('app').factory 'buildbotService',
['$log', '$resource',
($log, $resource) ->
# populateScope populate $scope[scope_key] with an api_query use
# sse_query and EventSource to update automatically the table
['$log', 'Restangular', 'EventSource',
($log, Restangular, EventSource) ->
configurer = (RestangularConfigurer) ->
onElemRestangularized = (elem, isCollection, route, Restangular) ->
# add the bind() method to each restangular object
# bind method will create one way binding (readonly)
# via event source
elem.bind = ($scope, scope_key) ->
if not scope_key?
scope_key = elem.route
if (isCollection)
onEvent = (e) ->
$scope[scope_key].push(e.msg)
$scope.$apply()
$scope[scope_key] = elem.getList()
$scope[scope_key].then ->
elem.on("new", onEvent)
else
onEvent = (e) ->
for k, v of e.msg
$scope[scope_key][k] = v
$scope.$apply()
$scope[scope_key] = this.get()
$scope[scope_key].then ->
elem.on("update", onEvent)
$scope.$on("$destroy", -> elem.source.close())
return $scope[scope_key]

# @todo the implementation is very naive for now. Need to sort out
# server side paging/sorting (do we really need serverside sorting?)
elem.unbind = () ->
this.source?.close()

elem.on = (event, onEvent) ->
if not elem.source?
route = elem.getRestangularUrl()
route = route.replace(BASEURLAPI, BASEURLSSE)
source = new EventSource(route)
elem.source = source
elem.source.addEventListener(event, onEvent)
return elem
RestangularConfigurer.setBaseUrl(BASEURLAPI)
RestangularConfigurer.setOnElemRestangularized(onElemRestangularized)

return Restangular.withConfig(configurer)

populateScope = ($scope, scope_key, api_query, sse_query) ->
$scope[scope_key] = $resource("api/v2/"+api_query).query()
source = new EventSource("sse/"+sse_query)
source.addEventListener "event", (e) ->
$scope[scope_key].push msg
$scope.$apply()
{populateScope}
]
28 changes: 28 additions & 0 deletions www/test/scripts/services/EventSourceMock.coffee
@@ -0,0 +1,28 @@
window.mockEventSource = ->
mocked = (url) ->
class EventSourceMock

constructor: (@url) ->
this.readyState = 1 # directly connect
this.onEvent = {}

addEventListener: (event, cb) ->
if not this.onEvent.hasOwnProperty(event)
this.onEvent[event] = []
this.onEvent[event].push(cb)

fakeEvent: (eventtype, event) ->
if this.onEvent.hasOwnProperty(eventtype)
for cb in this.onEvent[eventtype]
cb(event)

close: ->
this.readyState = 2

return new EventSourceMock(url)

# overrride "EventSource"
beforeEach module(($provide) ->
$provide.value("EventSource", mocked)
null # those module callbacks need to return null!
)
94 changes: 82 additions & 12 deletions www/test/scripts/services/buildbotService.coffee
@@ -1,20 +1,90 @@
beforeEach module 'app'

beforeEach ->
this.addMatchers { toEqualData: (expected) ->
angular.equals this.actual, expected }

describe 'buildbot service', ->
mockEventSource()
buildbotService = {}
$httpBackend = {}
EventSourceMock = {}
$scope = {}
beforeEach inject (_$httpBackend_, $injector) ->
$httpBackend = _$httpBackend_
$httpBackend.expectGET('api/v2/changes')
.respond []
$httpBackend = {}

injected = ($injector) ->
$httpBackend = $injector.get('$httpBackend')
$scope = $injector.get('$rootScope').$new()
buildbotService = $injector.get('buildbotService')

it 'should query for changes at /changes and receive an array', ->
beforeEach(inject(injected))

it 'should query for changes at /changes and receive an empty array', ->
$httpBackend.expectGET('api/v2/changes').respond([])
p = buildbotService.all("changes").bind($scope, "changes")
p.then((res) ->
expect(res.length).toBe(0)
)
$httpBackend.flush()

it 'should query for build/1/step/2 and receive a SUCCESS result', ->
$httpBackend.expectGET('api/v2/build/1/step/2').respond({res: "SUCCESS"})
r = buildbotService.one("build", 1).one("step", 2)
p = r.bind($scope, "step_scope")
p.then((res) ->
expect(res.res).toBe("SUCCESS")
)
$httpBackend.flush()

it 'should query default scope_key to route key', ->
$httpBackend.expectGET('api/v2/build/1/step/2').respond({res: "SUCCESS"})
p = buildbotService.one("build", 1).one("step", 2).bind($scope)
expect($scope.step).toBe(p)
$httpBackend.flush()
expect($scope.step).toBe(p)

it 'should close the eventsource on scope.$destroy()', ->
$httpBackend.expectGET('api/v2/build/1/step/2').respond({res: "SUCCESS"})
r = buildbotService.one("build", 1).one("step", 2)
p = r.bind($scope)
expect($scope.step).toBe(p)
$httpBackend.flush()
expect(r.source.readyState).toBe(1)
$scope.$destroy()
expect(r.source.readyState).toBe(2)

it 'should close the eventsource on unbind()', ->
$httpBackend.expectGET('api/v2/build/1/step/2').respond({res: "SUCCESS"})
r = buildbotService.one("build", 1).one("step", 2)
p = r.bind($scope)
expect($scope.step).toBe(p)
$httpBackend.flush()
expect(r.source.readyState).toBe(1)
r.unbind()
expect(r.source.readyState).toBe(2)

it 'should update the $scope when event received', ->
$httpBackend.expectGET('api/v2/build/1/step/2').respond({res: "PENDING", otherfield: "FOO"})
r = buildbotService.one("build", 1).one("step", 2)
p = r.bind($scope)
expect($scope.step).toBe(p)
p.then((res) ->
$scope.step=res # this is done automatically by ng in real environment but not in test
)
$httpBackend.flush()
expect(r.source.url).toBe("sse/build/1/step/2")
expect($scope.step.res).toBe("PENDING")
r.source.fakeEvent("update", {event: "update", msg: {res: "SUCCESS"}})
expect($scope.step.res).toBe("SUCCESS")
# should not override other fields
expect($scope.step.otherfield).toBe("FOO")

buildbotService.populateScope $scope, "changes", "changes", "changes"
# $httpBackend.flush()
it 'should update the $scope when event received for collections', ->
$httpBackend.expectGET('api/v2/build/1/step').respond([])
r = buildbotService.one("build", 1).all("step")
p = r.bind($scope)
expect($scope.step).toBe(p)
p.then((res) ->
$scope.step=res # this is done automatically by ng in real environment but not in test
)
$httpBackend.flush()
expect(r.source.url).toBe("sse/build/1/step")
expect($scope.step.length).toBe(0)
r.source.fakeEvent("new", {event: "new", msg: {res: "SUCCESS"}})
expect($scope.step.length).toBe(1)
expect($scope.step[0].res).toBe("SUCCESS")

0 comments on commit c353ddd

Please sign in to comment.