Skip to content

Instantly share code, notes, and snippets.

@foxnewsnetwork
Created May 15, 2015 23:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save foxnewsnetwork/727f47e6bb480390e98d to your computer and use it in GitHub Desktop.
Save foxnewsnetwork/727f47e6bb480390e98d to your computer and use it in GitHub Desktop.
EmberJS controller promise state

The Problem

I use EmberJS a lot in my day-to-day work, and one of the things I find myself constantly doing is writing acceptance tests (aka integration tests) that click through my application to ensure the various planned out user workflows are operating as expected.

However, because controller must handle user interaction and map that to necessarily async ajax (or websocket) calls, EmberJS's standard suite of acceptance test helpers are sometimes not enough to ensure proper waiting.

For example:

Suppose I have a very complex DS.Model which, at creation, must not only create itself in my rails back-end via ajax, but also communicate to a firebase server somewhere using websockets.

StationsWeighticketTrucksNewController = Ember.Controller.extend
  ...
  actions:
    finish: ->
      throttle @, 200, ->
        @get("truck").save()
        .then (truck) ->
          truck.gotoDock()
        .then (truck) ->
          truck.get "entryScaleIdPromise"
        .then (entryScaleId) =>
          Ember.assert "there is a entry scale id #{entryScaleId}", Ember.isPresent entryScaleId
          @transitionToRoute "stations.station", entryScaleId

Here, the finish action represents about 3 ajax requests, 2 websocket messages, and 1 router transition. (throttle is just Ember.run.throttle with the arguments slightly reversed for coffeescript reasons).

In my acceptance test, I would have something like:

...
before (done) ->
  click "finish"
  andThen -> done()

it "should create the truck and whatever", ->
  expect @truck
  .to.whatever

Unfortunately, this test does not consistently for me because the 2 websocket from firebase are not being properly waited on by the andThen helper.

EmberJS's wait test helper is reproduced here:

// https://github.com/emberjs/ember.js/blob/master/packages/ember-testing/lib/helpers.js#L201
function wait(app, value) {
  return new RSVP.Promise(function(resolve) {
    // Every 10ms, poll for the async thing to have finished
    var watcher = setInterval(function() {
      var router = app.__container__.lookup('router:main');

      // 1. If the router is loading, keep polling
      var routerIsLoading = router.router && !!router.router.activeTransition;
      if (routerIsLoading) { return; }

      // 2. If there are pending Ajax requests, keep polling
      if (Test.pendingAjaxRequests) { return; }

      // 3. If there are scheduled timers or we are inside of a run loop, keep polling
      if (run.hasScheduledTimers() || run.currentRunLoop) { return; }
      if (Test.waiters && Test.waiters.any(function(waiter) {
        var context = waiter[0];
        var callback = waiter[1];
        return !callback.call(context);
      })) {
        return;
      }
      // Stop polling
      clearInterval(watcher);

      // Synchronously resolve the promise
      run(null, resolve, value);
    }, 10);
  });

}

Ember checks for pending routes, ajax requests, and run loops, but nowhere does it deal with other forms of synchronicity such as websockets, webrtc, image.onload, and the (lolwhoamikidding) future web-worker... so what do you do if you must test but your application depends on these other forms of async behavior?

My Solution

TL;DR: make the application route aware of other forms of async behavior and monkey patch andThen to check the application controller during its polling.

# route/application.coffee
ApplicationRoute = Ember.Controller.extend
  init: ->
    @_super arguments...
    @controllerPen = ControllerPen.create()
  isBusy: Ember.computed.alias "controllerPen.isBusy"
  actions:
    controllerWorking: (controller) ->
      @controllerPen.makeBusy controller

    controllerFinished: (controller) ->
      @controllerPen.makeFree controller

# utils/controller-pen.coffee
ControllerPen = Ember.Object.extend
  init: ->
    @busyControllers = 0
    @ctrlCenter = new Ember.Map()
  isBusy: Ember.computed.not "isFree"
  isFree: Ember.computed.equal "busyControllers", 0
  makeBusy: (ctrl) ->
    return if @ctrlCenter.get(ctrl) is "busy"
    @ctrlCenter.set ctrl, "busy"
    @incrementProperty "busyControllers", 1

  makeFree: (ctrl) ->
    throw new Error freeMsg(ctrl) unless @ctrlCenter.has ctrl
    return if @ctrlCenter.get(ctrl) is "free"
    @ctrlCenter.set ctrl, "free"
    @decrementProperty "busyControllers", 1
    throw new Error negMsg if @get("busyControllers") < 0

# controllers/[all-other-controller]
StationsTruckExitController = Ember.Controller.extend AtomicMixin,
  actions:
    killTruck: ->
      @atomically =>
        @get "truck.exitScaleIdPromise"
        .then (exitScaleId) =>
          @get "truck"
          .destroyRecord()
          .then ->
            exitScaleId
        .then (scaleId) =>
          @transitionToRoute "stations.station", scaleId

# utils/atomic.coffee
AtomicMixin = Ember.Mixin.create
  isPending: Ember.computed.or "model.isPending", "model.isSaving", "isBusy"
  atomically: (action) ->
    return if @get "isBusy"
    Ember.run =>
      @send "controllerWorking", @
      @set "isBusy", true
      assertThenable action()
      .finally =>
        @set "isBusy", false
        @send "controllerFinished", @
# helpers/and-then
andThenOld = andThen
andThen = (action) ->
  poll applicationRoute
  .until (route) -> route.get("isBusy") is false
  .then ->
    andThenOld -> action

Now, it's up to each controller to inform the application route whether it is busy or free, which opens the door for all kinds of different html5 async behavior

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment