Repository: vigetlabs/microcosm
Pull request #276 (2017-03-28 18:12:08)

Add history.wait() feature to wait for all outstanding actions
On one of our current projects, we have a bit of a hack to wait for all actions to complete before running a test. I want to more formally support that, and add some documentation around how it works. 

This commit adds promise interop for action history. This is useful for waiting for history to complete before doing some other type of work. It also adds documentation for history, which is somewhat unstable still, something we clarify in the doc.

With this PR, you can now do:

```javascript
describe('An AJAX behavior', function() {

  it('adds a user', async function () {
    let repo = new MyMicrocosm({ maxHistory: Infinity })

    repo.push(getUser, 1)
    repo.push(getUser, 2)
    repo.push(getUser, 3)

    await repo.history

    expect(repo.users.length).toEqual(3)
  })

})
```

I need way more infographics for this. Please help!

---

Related issues:
https://github.com/vigetlabs/microcosm/issues/232

codecov-io: # [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=h1) Report
> Merging [#276](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=desc) into [master](https://codecov.io/gh/vigetlabs/microcosm/commit/3bb859d4d185ae7e718ffd3c47aec5bd5f58caad?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/vigetlabs/microcosm/pull/276/graphs/tree.svg?src=pr&token=AaE0FLz9sU&width=650&height=150)](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #276   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          23     23           
  Lines         751    761   +10     
=====================================
+ Hits          751    761   +10
```


| [Impacted Files](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/history.js](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=tree#diff-c3JjL2hpc3RvcnkuanM=) | `100% <100%> (ø)` | :arrow_up: |
| [src/addons/presenter.js](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=tree#diff-c3JjL2FkZG9ucy9wcmVzZW50ZXIuanM=) | `100% <0%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=footer). Last update [3bb859d...d883352](https://codecov.io/gh/vigetlabs/microcosm/pull/276?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).
efatsi: Code looks great to me 👍 
mackermedia: Kinda weird to see tests without explicit assertions, maybe that's an okay thing. Will an unhandled promise cause the test suite to fail?

Otherwise, 👍 
nhunzaker: @mackermedia A rejected promise will fail a Jest test if you return it from the test function.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00ab10a8..5ef71aa7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,14 @@
 # Changelog
 
+## 12.4.0
+
+- Added `repo.history.wait()` and `repo.history.then()` to allow tests
+  to wait for all outstanding actions to complete.
+- Added `repo.history` documentation.
+
+
 ## 12.3.1
+
 - Fix bug where Presenters weren't intercepting actions sent from
 child views.
 
diff --git a/docs/README.md b/docs/README.md
index 9fe5d862..aadbb25a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,7 +10,8 @@
 2. [Domains](api/domains.md)
 3. [Actions](api/actions.md)
 4. [Effects](api/effects.md)
-5. [Immutability Helpers](api/immutability-helpers.md)
+5. [History](api/history.md)
+6. [Immutability Helpers](api/immutability-helpers.md)
 
 ## Addons
 
diff --git a/docs/api/history.md b/docs/api/history.md
new file mode 100644
index 00000000..27f79811
--- /dev/null
+++ b/docs/api/history.md
@@ -0,0 +1,192 @@
+# History
+
+1. [Overview](#overview)
+2. [API](#api)
+
+**Note: This is a work in progress document and feature. History has
+been a private API for a very long time. We're still working through
+the best public interface for working with Microcosm's action
+history.**
+
+## Overview
+
+All Microcosms have a history. This history keeps track of outstanding
+actions, working with a Microcosm to determine the next application
+state as actions move through different states.
+
+By default, Microcosm automatically disposes actions that are
+complete. However you can control how many actions Microcosm's history
+will hold on to by passing the `maxHistory` option:
+
+```javascript
+// Hold onto the last 10 actions
+let repo = new Microcosm({ maxHistory: 10 })
+
+// Or never forget anything
+let repo = new Microcosm({ maxHistory: Infinity })
+
+// Then push some actions
+repo.push(action)
+repo.push(action)
+repo.push(action)
+
+// Get actions out by using `repo.history.toArray()`
+let actions = repo.history.toArray() // [actionOne, actionTwo, actionThree]
+```
+
+### The data structure
+
+History stores actions as a tree, with a root and a head:
+
+```
+[root] - [one] - [two] - [three]
+```
+
+You can revert to a prior action by using `checkout(action)`:
+
+```javascript
+let repo = new Microcosm({ maxHistory: Infinity })
+
+let one = repo.push(action)
+let two = repo.push(action)
+
+repo.history.checkout(one)
+
+let three = repo.push(action)
+```
+
+In the example above, the following tree would be produced:
+
+```
+               |- [two]
+[root] - [one] +
+               |- [*three]
+```
+
+`three` is the active branch, marked with an asterisk. The active
+branch is used to determine state. Here, a Microcosm's state will be
+the result of dispatching `root`, `one`, and `three` to Domains.
+
+## API
+
+### checkout(action)
+
+Set the head of the tree to a target action. This has the effect
+of controlling time in a Microcosm's history:
+
+```javascript
+let repo = new Microcosm({ maxHistory: Infinity })
+
+let one = repo.push(action)
+let two = repo.push(action)
+
+repo.history.checkout(one)
+
+let three = repo.push(action)
+```
+
+The head of the history tree above is now three. Microcosm will
+calculate state by reconciling `root`, `one,` and `three`.
+
+
+### toggle([ ...actions ])
+
+Disable a group of actions:
+
+```
+let repo = new Microcosm({ maxHistory: Infinity })
+
+let one = repo.push(addUser)
+let two = repo.push(updateUser)
+
+repo.history.toggle([ one, two ])
+```
+
+In the example above, actions `one` and `two` will be forgotten. The
+repo's history will reconcile, and it will be as if they never
+existed.
+
+This flips the `disabled` state of each action provided. By executing
+toggle a second time, these actions will be re-enabled:
+
+```
+// Actions disabled in the prior example
+repo.history.toggle([ one, two ])
+// `one` and `two` have been re-enabled
+```
+
+### toArray()
+
+Return an array version of the active branch:
+
+```javascript
+let repo = new Microcosm({ maxHistory: Infinity })
+
+let one = repo.push(action)
+let two = repo.push(action)
+
+repo.history.checkout(one)
+
+let three = repo.push(action)
+
+repo.toArray() // => [ root, one, three ]
+```
+
+### wait()
+
+Return a Promise that waits for all current actions to complete. If
+any action rejects, this promise will reject. **cancellation is
+ignored. Cancelling an action will not reject this promise.**
+
+```javascript
+let repo = new Microcosm({ maxHistory: Infinity })
+
+let one = repo.push(asyncAction)
+let two = repo.push(asyncAction)
+let three = repo.push(asyncAction)
+
+repo.wait().then(function () {
+  // Everything is done
+})
+```
+
+### then(resolve, reject)
+
+Allows for direct Promise interop with history. One common use case
+for this is to wait for all actions to complete before executing a
+test. For example, if we were to write a test with Jest:
+
+```javascript
+describe('An AJAX behavior', function() {
+
+  it('adds a user', async function () {
+    let repo = new MyMicrocosm({ maxHistory: Infinity })
+
+    repo.push(getUser, 1)
+    repo.push(getUser, 2)
+    repo.push(getUser, 3)
+
+    await repo.history
+
+    expect(repo.users.length).toEqual(3)
+  })
+
+})
+```
+
+### remove(action)
+
+Completely remove an action from history. This is a dangerous
+operation! Removed actions can never be re-inserted.
+
+```javascript
+let repo = new MyMicrocosm({ maxHistory: Infinity })
+
+let one = repo.push(action)
+let two = repo.push(action)
+let three = repo.push(action)
+
+repo.history.remove(two)
+
+repo.history.toArray() // [root, one, three]
+```
diff --git a/package.json b/package.json
index 42c6637e..e04f3f56 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "microcosm",
-  "version": "12.3.1",
+  "version": "12.4.0",
   "private": true,
   "description": "Flux with actions at center stage. Write optimistic updates, cancel requests, and track changes with ease.",
   "main": "microcosm.js",
diff --git a/site/src/layouts/default.html b/site/src/layouts/default.html
index 743e344b..d6f0f02c 100644
--- a/site/src/layouts/default.html
+++ b/site/src/layouts/default.html
@@ -46,6 +46,7 @@
             <li><a href="$base_url$/api/domains.html">Domains</a></li>
             <li><a href="$base_url$/api/actions.html">Actions</a></li>
             <li><a href="$base_url$/api/effects.html">Effects</a></li>
+            <li><a href="$base_url$/api/history.html">History</a></li>
             <li><a href="$base_url$/api/immutability-helpers.html">Immutability Helpers</a></li>
           </ul>
         </section>
diff --git a/src/history.js b/src/history.js
index 36118659..9aa15dc9 100644
--- a/src/history.js
+++ b/src/history.js
@@ -29,6 +29,91 @@ export default function History (limit) {
 
 inherit(History, Emitter, {
 
+  /**
+   * Set the head of the tree to a target action. This has the effect
+   * of controlling time in a Microcosm's history.
+   * @param {Action} action The new head of the tree
+   */
+  checkout (action) {
+    this.head = action || this.head
+
+    this.setActiveBranch()
+
+    this.reconcile(this.head)
+
+    return this
+  },
+
+  /**
+   * Toggle actions in bulk, then reconcile from the first action
+   * @param {Action[]} - A list of actions to toggle
+   * @public
+   */
+  toggle (actions) {
+    let list = [].concat(actions)
+
+    list.forEach(action => action.toggle('silently'))
+
+    this.reconcile(list[0])
+  },
+
+  /**
+   * Convert the active branch of history into an array.
+   */
+  toArray () {
+    return this.map(n => n)
+  },
+
+  /**
+   * Map over the active branch.
+   */
+  map (fn, scope) {
+    let size = this.size
+    let items = Array(size)
+    let action = this.head
+
+    while (size--) {
+      items[size] = fn.call(scope, action)
+      action = action.parent
+    }
+
+    return items
+  },
+
+  /**
+   * Return a promise that represents the resolution of all actions in
+   * the current branch.
+   * @returns {Promise}
+   */
+  wait () {
+    let actions = this.toArray()
+    let count = actions.length
+
+    return new Promise (function (resolve, reject) {
+      function checkStatus () {
+        count -= 1
+
+        if (count <= 0) {
+          resolve()
+        }
+      }
+
+      actions.map(function (action) {
+        action.onDone(checkStatus)
+        action.onCancel(checkStatus)
+        action.onError(reject)
+      })
+    })
+  },
+
+  /**
+   * Chain off of wait(). Provides a promise interface
+   * @returns {Promise}
+   */
+  then (pass, fail) {
+    return this.wait().then(pass, fail)
+  },
+
   /**
    * Setup the head and root action for a history. This effectively
    * starts or restarts history.
@@ -63,16 +148,6 @@ inherit(History, Emitter, {
     }
   },
 
-  checkout (action) {
-    this.head = action || this.head
-
-    this.setActiveBranch()
-
-    this.reconcile(this.head)
-
-    return this
-  },
-
   append (command, status) {
     let action = new Action(command, status, this)
 
@@ -189,35 +264,6 @@ inherit(History, Emitter, {
     }
 
     this.size = size
-  },
-
-  /**
-   * Toggle actions in bulk, then reconcile from the first action
-   * @param {Action[]} - A list of actions to toggle
-   */
-  toggle (actions) {
-    let list = [].concat(actions)
-
-    list.forEach(action => action.toggle('silently'))
-
-    this.reconcile(list[0])
-  },
-
-  map (fn, scope) {
-    let size = this.size
-    let items = Array(size)
-    let action = this.head
-
-    while (size--) {
-      items[size] = fn.call(scope, action)
-      action = action.parent
-    }
-
-    return items
-  },
-
-  toArray () {
-    return this.map(n => n)
   }
 
 })


Test patch:
diff --git a/test/unit/history/then.test.js b/test/unit/history/then.test.js
new file mode 100644
index 00000000..5d065f73
--- /dev/null
+++ b/test/unit/history/then.test.js
@@ -0,0 +1,42 @@
+import History from '../../../src/history'
+
+describe('History::then', function () {
+  const action = n => n
+
+  it('allows direct interop with promises', function () {
+    const history = new History()
+
+    let one = history.append(action)
+    let two = history.append(action)
+    let three = history.append(action)
+
+    setTimeout(function() {
+      one.resolve()
+      two.resolve()
+      three.resolve()
+    }, 10)
+
+    // This will fail if the promise returned from `history.then`
+    // rejects, it will only pass when the promise resolves.
+    return history
+  })
+
+  it('passes a failure callback', function () {
+    const history = new History()
+
+    let one = history.append(action)
+    let two = history.append(action)
+    let three = history.append(action)
+
+    setTimeout(function() {
+      one.resolve()
+      two.resolve()
+      three.reject('Error')
+    }, 10)
+
+    return history.then(null, function (error) {
+      expect(error).toEqual('Error')
+    })
+  })
+
+})
diff --git a/test/unit/history/wait.test.js b/test/unit/history/wait.test.js
new file mode 100644
index 00000000..b86021bd
--- /dev/null
+++ b/test/unit/history/wait.test.js
@@ -0,0 +1,60 @@
+import History from '../../../src/history'
+
+describe('History::wait', function () {
+  const action = n => n
+
+  it('resolves when every action completes successfull', function () {
+    const history = new History()
+
+    let one = history.append(action)
+    let two = history.append(action)
+    let three = history.append(action)
+
+    setTimeout(function() {
+      one.resolve()
+      two.resolve()
+      three.resolve()
+    }, 10)
+
+    return history.wait()
+  })
+
+  it('fails when an action rejects', async function () {
+    const history = new History(Infinity)
+
+    let one = history.append(action)
+    let two = history.append(action)
+    let three = history.append(action)
+
+    setTimeout(function() {
+      one.resolve()
+      two.resolve()
+      three.reject('Wut')
+    }, 10)
+
+    try {
+      await history.wait()
+    } catch (error) {
+      expect(error).toEqual('Wut')
+    }
+  })
+
+  it('ignores cancelled actions', function () {
+    const history = new History(Infinity)
+
+    let one = history.append(action)
+    let two = history.append(action)
+    let three = history.append(action)
+
+    setTimeout(function() {
+      one.resolve()
+      two.cancel()
+      three.resolve('Wut')
+    }, 10)
+
+    // This will fail if the promise returned from `wait()` rejects, and
+    // it will only pass when the promise resolves.
+    return history.wait()
+  })
+
+})