Repository: bokeh/bokeh

Issue:
Rewrite bokehjs' tests in TypeScript
A follow up on #6481. Writing tests in TypeScript is an important part of verifying that our typings actually work, especially parts that can't be verified within the lib itself. This is also an opportunity to clean up the atrocious mess that bokehjs' unit tests are.

Rewrite progress:

- [x] client
- [x] core
- [x] document
- [x] models
- [x] protocol


Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -12,10 +12,10 @@
 * A JavaScript runtime BokehJS that draws and handles events in browsers
 * Python "bindings" and an optional server for interacting with BokehJS
 
-The BokehJS library is written in a mixture of CoffeeScript, TypeScript, and
-pure JavaScript. This necessitates a "compilation" step to build a complete
-BokehJS from these sources, and this fact makes the Bokeh setup and install
-more complicated than typical pure Python projects.
+The BokehJS library is written in a mixture of TypeScript and pure JavaScript.
+This necessitates a "compilation" step to build a complete BokehJS from these
+sources, and this fact makes the Bokeh setup and install more complicated than
+typical pure Python projects.
 
 In order to build BokehJS, the first step is to make sure that the "npm"
 command is installed. If you are using conda, you can typically just run


Test patch:
diff --git a/bokehjs/make/tasks/test.ts b/bokehjs/make/tasks/test.ts
--- a/bokehjs/make/tasks/test.ts
+++ b/bokehjs/make/tasks/test.ts
@@ -1,24 +1,15 @@
 import {spawn} from "child_process"
 import {argv} from "yargs"
-import {join} from "path"
 
 import {task, log, BuildError} from "../task"
 import {compileTypeScript} from "../compiler"
-import {read, write, scan, rename} from "../fs"
 import * as paths from "../paths"
 
-const coffee = require("coffeescript")
-
 task("test:compile", async () => {
   const success = compileTypeScript("./test/tsconfig.json", {log})
 
   if (argv.emitError && !success)
     process.exit(1)
-
-  for (const file of scan("./test", [".coffee"])) {
-    const js = coffee.compile(read(file)!, {bare: true})
-    write(join("./build", rename(file, {ext: ".js"})), js)
-  }
 })
 
 function mocha(files: string[]): Promise<void> {
diff --git a/bokehjs/test/client/connection.coffee b/bokehjs/test/client/connection.coffee
deleted file mode 100644
--- a/bokehjs/test/client/connection.coffee
+++ /dev/null
@@ -1,199 +0,0 @@
-chai = require "chai"
-chai.use(require "chai-as-promised")
-expect = chai.expect
-child_process = require "child_process"
-path = require "path"
-net = require "net"
-
-# node.js compat shim for WebSocket
-global.WebSocket = require("websocket").w3cwebsocket
-
-{Document, ModelChangedEvent} = require "document"
-{pull_session} = require "client/connection"
-{Range1d} = require "models/ranges/range1d"
-
-# Promise works in a very annoying way, make it
-# have resolve and reject methods instead
-promise_with_methods = () ->
-  capture_resolve = null
-  capture_reject = null
-  promise = new Promise (resolve, reject) ->
-    capture_resolve = resolve
-    capture_reject = reject
-  promise.resolve = (value) ->
-    capture_resolve(value)
-  promise.reject = (value) ->
-    capture_reject(value)
-  promise
-
-_previous_port = 5007
-next_port = () ->
-  _previous_port += 1
-  _previous_port
-
-server_timeout_millis = 7500
-
-# Launch server, wait for it to be alive, and then
-# run a test function that returns a Promise
-with_server = (f) ->
-  promise = promise_with_methods()
-  all_done = false
-  mark_done = (value_or_error) ->
-    all_done = true
-  promise.then(mark_done, mark_done)
-
-  basedir = path.normalize(process.cwd() + "/..")
-  oldpath = process.env['PYTHONPATH']
-  if oldpath?
-    pypath = "#{basedir}:#{oldpath}"
-  else
-    pypath = basedir
-  port = next_port()
-  env = Object.assign({}, process.env, { PYTHONPATH: pypath })
-  handle = child_process.spawn("python", ["-m", "bokeh", "serve", "--port=#{port}"], {
-    env: env,
-    cwd: basedir
-  })
-  handle.on 'close', (code) ->
-    promise.reject(new Error("Server exited before test promise was resolved"))
-
-  cleanup_process = (value_or_error) ->
-    handle.kill()
-
-  promise.then(cleanup_process, cleanup_process)
-
-  runF = () ->
-    if all_done
-      return
-    try
-      # yay javascript, stick attributes on anything!
-      handle.url = "ws://localhost:#{port}/ws"
-      v = f(handle)
-      # note that "v" can be another promise OR a final value
-      promise.resolve(v)
-    catch e
-      promise.reject(e)
-
-  client = null
-  server_ready = false
-  num_server_attempts = 0
-  checkServer = () ->
-    if all_done or server_ready
-      if client?
-        client.destroy()
-        client = null
-    else if num_server_attempts > (server_timeout_millis / 100)
-      promise.reject(new Error("Failed to connect to the server"))
-    else if client != null
-      # still waiting on a client we already have...
-      setTimeout checkServer, 100
-    else
-      num_server_attempts = num_server_attempts + 1
-      client = net.connect(port, 'localhost')
-      client.on 'error', () ->
-        client.destroy()
-        client = null
-        if not (all_done or server_ready)
-          setTimeout checkServer, 100
-      client.on 'connect', () ->
-        client.destroy()
-        client = null
-        server_ready = true
-        setTimeout runF, 0
-
-  setTimeout checkServer, 100
-
-  promise
-
-describe "ClientSession", ->
-
-  # It takes time to spin up the server so without this things get
-  # flaky on Travis. Lengthen if we keep getting Travis failures.
-  # The default (at least when this comment was written) was
-  # 2000ms.
-  @timeout(server_timeout_millis)
-
-  it "should be able to connect", ->
-    promise = with_server (server_process) ->
-      pull_session(server_process.url).then(
-        (session) ->
-          session.close()
-          "OK"
-        (error) ->
-          throw error
-      )
-    expect(promise).eventually.to.equal("OK")
-
-  it "should pass request string to connection", ->
-    promise = with_server (server_process) ->
-      pull_session(server_process.url, null, "foo=10&bar=20").then(
-        (session) ->
-          expect(session._connection.args_string).to.be.equal "foo=10&bar=20"
-          "OK"
-      )
-    expect(promise).eventually.to.equal("OK")
-
-  it "should be able to connect", ->
-    promise = with_server (server_process) ->
-      pull_session(server_process.url).then(
-        (session) ->
-          session.close()
-          "OK"
-        (error) ->
-          throw error
-      )
-    expect(promise).eventually.to.equal("OK")
-
-   it "should get server info", ->
-     promise = with_server (server_process) ->
-       pull_session(server_process.url).then(
-         (session) ->
-           session.request_server_info().then(
-             (info) ->
-               expect(info).to.have.property('version_info')
-               "OK"
-           )
-        )
-     expect(promise).eventually.to.equal("OK")
-
-  it "should sync a document between two connections", ->
-    promise = with_server (server_process) ->
-      added_root = pull_session(server_process.url).then(
-        (session) ->
-          root1 = new Range1d({start: 123, end: 456})
-          session.document.add_root(root1)
-          session.document.set_title("Hello Title")
-          session.force_roundtrip().then((ignored) -> session)
-        (error) ->
-          throw error
-      ).catch (error) ->
-        throw error
-
-      added_root.then(
-        (session1) ->
-          ok = pull_session(server_process.url, session1.id).then(
-            (session2) ->
-              try
-                expect(session2.document.roots().length).to.equal 1
-                root = session2.document.roots()[0]
-                expect(root.start).to.equal 123
-                expect(root.end).to.equal 456
-                expect(session2.document.title()).to.equal "Hello Title"
-              catch e
-                throw e
-              finally
-                session1.close()
-                session2.close()
-              "OK"
-            (error) ->
-              session1.close()
-              throw error
-          )
-          # es6 promises would swallow the test errors otherwise
-          ok.catch (error) ->
-            throw error
-        (error) ->
-          throw error
-      ).catch (error) ->
-          throw error
-    expect(promise).eventually.to.equal("OK")
diff --git a/bokehjs/test/client/connection.ts b/bokehjs/test/client/connection.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/client/connection.ts
@@ -0,0 +1,237 @@
+import * as chai from "chai"
+import * as chai_as_promised from "chai-as-promised"
+chai.use(chai_as_promised)
+const {expect} = chai
+
+import * as child_process from "child_process"
+import * as path from "path"
+import * as net from "net"
+
+// node.js compat shim for WebSocket
+(global as any).WebSocket = require("websocket").w3cwebsocket
+
+import {pull_session} from "client/connection"
+import {Range1d} from "models/ranges/range1d"
+
+// Promise works in a very annoying way, make it
+// have resolve and reject methods instead
+type PromiseWithMethods<T> = Promise<T> & {
+  resolve(value?: T): void
+  reject(reason?: unknown): void
+}
+
+function promise_with_methods<T>(): PromiseWithMethods<T> {
+  let capture_resolve: (value?: any) => void
+  let capture_reject: (reason?: any) => void
+
+  const promise = new Promise((resolve, reject) => {
+    capture_resolve = resolve
+    capture_reject = reject
+  }) as PromiseWithMethods<T>
+
+  promise.resolve = (value) => capture_resolve(value)
+  promise.reject = (value) => capture_reject(value)
+
+  return promise
+}
+
+let _previous_port = 5007
+function next_port(): number {
+  _previous_port += 1
+  return _previous_port
+}
+
+const server_timeout_millis = 7500
+
+// Launch server, wait for it to be alive, and then
+// run a test function that returns a Promise
+function with_server<T>(f: (x: {url: string}) => T): Promise<T> {
+  const promise = promise_with_methods<T>()
+
+  let all_done = false
+  const mark_done = () => all_done = true
+  promise.then(mark_done, mark_done)
+
+  const basedir = path.normalize(process.cwd() + "/..")
+  const oldpath = process.env.PYTHONPATH
+  const pypath = oldpath != null ? `${basedir}:${oldpath}` : basedir
+  const port = next_port()
+  const env = {...process.env, PYTHONPATH: pypath}
+  const handle = child_process.spawn("python", ["-m", "bokeh", "serve", `--port=${port}`], {env, cwd: basedir})
+  handle.on('close', () => {
+    promise.reject(new Error("Server exited before test promise was resolved"))
+  })
+
+  const cleanup_process = () => handle.kill()
+  promise.then(cleanup_process, cleanup_process)
+
+  const runF = () => {
+    if (all_done)
+      return
+    try {
+      // yay javascript, stick attributes on anything!
+      const url = `ws://localhost:${port}/ws`
+      const v = f({url})
+      // note that "v" can be another promise OR a final value
+      promise.resolve(v)
+    } catch (e) {
+      promise.reject(e)
+    }
+  }
+
+  let client: net.Socket | null = null
+  let server_ready = false
+  let num_server_attempts = 0
+  const checkServer = () => {
+    if (all_done || server_ready) {
+      if (client != null) {
+        client.destroy()
+        client = null
+      }
+    } else if (num_server_attempts > (server_timeout_millis / 100)) {
+      promise.reject(new Error("Failed to connect to the server"))
+    } else if (client != null) {
+      // still waiting on a client we already have...
+      setTimeout(checkServer, 100)
+    } else {
+      num_server_attempts = num_server_attempts + 1
+      client = net.connect(port, 'localhost')
+      client.on('error', () => {
+        client!.destroy()
+        client = null
+        if (!(all_done || server_ready))
+          setTimeout(checkServer, 100)
+      })
+      client.on('connect', () => {
+        client!.destroy()
+        client = null
+        server_ready = true
+        setTimeout(runF, 0)
+      })
+    }
+  }
+
+  setTimeout(checkServer, 100)
+
+  return promise
+}
+
+describe("ClientSession", function() {
+
+  // It takes time to spin up the server so without this things get
+  // flaky on Travis. Lengthen if we keep getting Travis failures.
+  // The default (at least when this comment was written) was
+  // 2000ms.
+  this.timeout(server_timeout_millis)
+
+  it("should be able to connect", async () => {
+    const promise = with_server((server_process) => {
+      return pull_session(server_process.url).then(
+        (session) => {
+          session.close()
+          return "OK"
+        },
+        (error) => {
+          throw error
+        },
+      )
+    })
+    return expect(promise).eventually.to.equal("OK")
+  })
+
+  it("should pass request string to connection", async () => {
+    const promise = with_server((server_process) => {
+      return pull_session(server_process.url, undefined, "foo=10&bar=20").then(
+        (session) => {
+          expect((session as any)._connection.args_string).to.be.equal("foo=10&bar=20") // XXX
+          return "OK"
+        },
+      )
+    })
+    return expect(promise).eventually.to.equal("OK")
+  })
+
+  it("should be able to connect", async () => {
+    const promise = with_server((server_process) => {
+      return pull_session(server_process.url).then(
+        (session) => {
+          session.close()
+          return "OK"
+        },
+        (error) => {
+          throw error
+        },
+      )
+    })
+    return expect(promise).eventually.to.equal("OK")
+  })
+
+  it("should get server info", async () => {
+    const promise = with_server((server_process) => {
+      return pull_session(server_process.url).then(
+        (session) => {
+          return session.request_server_info().then(
+            (info) => {
+              expect(info).to.have.property('version_info')
+              return "OK"
+            },
+          )
+        },
+       )
+    })
+    return expect(promise).eventually.to.equal("OK")
+  })
+
+  it("should sync a document between two connections", async () => {
+    const promise = with_server((server_process) => {
+      const added_root = pull_session(server_process.url).then(
+        (session) => {
+          const root1 = new Range1d({start: 123, end: 456})
+          session.document.add_root(root1)
+          session.document.set_title("Hello Title")
+          return session.force_roundtrip().then((_ignored) => session)
+        },
+        (error) => {
+          throw error
+        },
+      ).catch((error) => {
+        throw error
+      })
+
+      return added_root.then(
+        (session1) => {
+          return pull_session(server_process.url, session1.id).then(
+            (session2) => {
+              try {
+                expect(session2.document.roots().length).to.equal(1)
+                const root = session2.document.roots()[0]
+                expect(root.start).to.equal(123)
+                expect(root.end).to.equal(456)
+                expect(session2.document.title()).to.equal("Hello Title")
+              } catch (e) {
+                throw e
+              } finally {
+                session1.close()
+                session2.close()
+              }
+              return "OK"
+            },
+            (error) => {
+              session1.close()
+              throw error
+            },
+          ).catch((error) => {
+            // es6 promises would swallow the test errors otherwise
+            throw error
+          })
+        },
+        (error) => {
+          throw error
+        },
+      ).catch((error) => {
+        throw error
+      })
+    })
+    return expect(promise).eventually.to.equal("OK")
+  })
+})
diff --git a/bokehjs/test/core/ui_events.coffee b/bokehjs/test/core/ui_events.coffee
deleted file mode 100644
--- a/bokehjs/test/core/ui_events.coffee
+++ /dev/null
@@ -1,502 +0,0 @@
-{expect, assert} = require "chai"
-sinon = require 'sinon'
-
-dom = require("core/dom")
-{Tap, MouseMove} = require("core/bokeh_events")
-
-{CrosshairTool} = require("models/tools/inspectors/crosshair_tool")
-{PanTool} = require("models/tools/gestures/pan_tool")
-{PolySelectTool} = require("models/tools/gestures/poly_select_tool")
-{SelectTool, SelectToolView} = require("models/tools/gestures/select_tool")
-{TapTool} = require("models/tools/gestures/tap_tool")
-{WheelZoomTool} = require("models/tools/gestures/wheel_zoom_tool")
-
-{Canvas} = require("models/canvas/canvas")
-{Document} = require("document")
-{Legend} = require("models/annotations/legend")
-{Plot} = require("models/plots/plot")
-{Range1d} = require("models/ranges/range1d")
-{Toolbar} = require("models/tools/toolbar")
-{UIEvents} = require "core/ui_events"
-
-describe "ui_events module", ->
-
-  afterEach ->
-    UIEvents.prototype._configure_hammerjs.restore()
-
-  beforeEach ->
-    sinon.stub(UIEvents.prototype, "_configure_hammerjs")
-
-    doc = new Document()
-    @plot = new Plot({
-      x_range: new Range1d({start: 0, end: 1})
-      y_range: new Range1d({start: 0, end: 1})
-    })
-    doc.add_root(@plot)
-    @plot_view = new @plot.default_view({model: @plot, parent: null}).build()
-    @ui_events = @plot_view.ui_event_bus
-
-  describe "_trigger method", ->
-
-    afterEach ->
-      @spy_trigger.restore()
-
-    beforeEach ->
-      @spy_trigger = sinon.spy(@ui_events, "trigger")
-
-    describe "base_type=move", ->
-
-      afterEach ->
-        @spy_cursor.restore()
-
-      beforeEach ->
-        @e = {type: "mousemove"}
-
-        @spy_cursor = sinon.spy(@plot_view, "set_cursor")
-
-      it "should trigger move event for active inspectors", ->
-        inspector = new CrosshairTool({active: true})
-        @plot.add_tools(inspector)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-
-        assert(@spy_trigger.calledOnce)
-        expect(@spy_trigger.args[0]).to.be.deep.equal([@ui_events.move, @e, inspector.id])
-
-      it "should not trigger move event for inactive inspectors", ->
-        inspector = new CrosshairTool({active: false})
-        @plot.add_tools(inspector)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-
-        assert(@spy_trigger.notCalled)
-
-      it "should use default cursor no active inspector", ->
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-
-        assert(@spy_cursor.calledOnce)
-        assert(@spy_cursor.calledWith("default"))
-
-      it "should use default cursor if active inspector but mouse is off-frame", ->
-        inspector = new CrosshairTool()
-        @plot.add_tools(inspector)
-
-        ss = sinon.stub(@ui_events, "_hit_test_frame").returns(false)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-        assert(@spy_cursor.calledOnce)
-        assert(@spy_cursor.calledWith("default"))
-
-        ss.restore()
-
-      it "should change cursor if active inspector is present and over frame", ->
-        inspector = new CrosshairTool()
-        @plot.add_tools(inspector)
-
-        ss = sinon.stub(@ui_events, "_hit_test_frame").returns(true)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-        assert(@spy_cursor.calledOnce)
-        assert(@spy_cursor.calledWith("crosshair"))
-
-        ss.restore()
-
-      it "should change cursor on view_renderer with cursor method", ->
-        legend = new Legend({click_policy: "mute"})
-        legend_view = new legend.default_view({model: legend, parent: this.plot_view})
-
-        ss = sinon.stub(@ui_events, "_hit_test_renderers").returns(legend_view)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-        assert(@spy_cursor.calledOnce)
-        assert(@spy_cursor.calledWith("pointer"))
-
-        ss.restore()
-
-      it "should override event_type if active inspector clashes with view renderer", ->
-        inspector = new CrosshairTool()
-        @plot.add_tools(inspector)
-
-        legend = new Legend({click_policy: "mute"})
-        legend_view = new legend.default_view({model: legend, parent: this.plot_view})
-
-        ss = sinon.stub(@ui_events, "_hit_test_renderers").returns(legend_view)
-
-        @ui_events._trigger(@ui_events.move, @e, {target: null})
-        assert(@spy_trigger.calledOnce)
-        expect(@spy_trigger.args[0]).to.be.deep.equal([@ui_events.move_exit, @e, inspector.id])
-        # should also use view renderer cursor and not inspector cursor
-        assert(@spy_cursor.calledOnce)
-        assert(@spy_cursor.calledWith("pointer"))
-
-        ss.restore()
-
-    describe "base_type=tap", ->
-
-      beforeEach ->
-        @e = {type: "tap", sx: 10, sy: 15, shiftKey: false}
-
-      it "should not trigger tap event if no active tap tool", ->
-        @ui_events._trigger(@ui_events.tap, @e, {target: null})
-        assert(@spy_trigger.notCalled)
-
-      it "should trigger tap event if exists an active tap tool", ->
-        gesture = new TapTool()
-        @plot.add_tools(gesture)
-
-        @ui_events._trigger(@ui_events.tap, @e, {target: null})
-
-        assert(@spy_trigger.calledOnce)
-        expect(@spy_trigger.args[0]).to.be.deep.equal([@ui_events.tap, @e, gesture.id])
-
-      it "should call on_hit method on view renderer if exists", ->
-        legend = new Legend({click_policy: "mute"})
-        legend_view = new legend.default_view({model: legend, parent: this.plot_view})
-
-        ss = sinon.stub(@ui_events, "_hit_test_renderers").returns(legend_view)
-        on_hit = sinon.stub(legend_view, "on_hit")
-
-        @ui_events._trigger(@ui_events.tap, @e, {target: null})
-        assert(on_hit.calledOnce)
-        expect(on_hit.args[0]).to.be.deep.equal([10, 15])
-
-        on_hit.restore()
-        ss.restore()
-
-    describe "base_type=scroll", ->
-
-      afterEach ->
-        @preventDefault.restore()
-        @stopPropagation.restore()
-
-      beforeEach ->
-        @e = {type: "wheel"}
-        @srcEvent = new Event("scroll")
-
-        @preventDefault = sinon.spy(@srcEvent, "preventDefault")
-        @stopPropagation = sinon.spy(@srcEvent, "stopPropagation")
-
-      it "should not trigger scroll event if no active scroll tool", ->
-        @plot.toolbar.gestures["scroll"].active = null
-        @ui_events._trigger(@ui_events.scroll, @e, @srcEvent)
-        assert(@spy_trigger.notCalled)
-
-        # assert that default scrolling isn't hijacked
-        assert(@preventDefault.notCalled)
-        assert(@stopPropagation.notCalled)
-
-      it "should trigger scroll event if exists an active tap tool", ->
-        gesture = new WheelZoomTool()
-        @plot.add_tools(gesture)
-        # unclear why add_tools doesn't activate the tool, so have to do it manually
-        @plot.toolbar.gestures['scroll'].active = gesture
-
-        @ui_events._trigger(@ui_events.scroll, @e, @srcEvent)
-
-        # assert that default scrolling is disabled
-        assert(@preventDefault.calledOnce)
-        assert(@stopPropagation.calledOnce)
-
-        assert(@spy_trigger.calledOnce)
-        expect(@spy_trigger.args[0]).to.be.deep.equal([@ui_events.scroll, @e, gesture.id])
-
-    describe "normally propagate other gesture base_types", ->
-
-      beforeEach ->
-        @e = {type: "pan"}
-
-      it "should not trigger event if no active tool", ->
-        @ui_events._trigger(@ui_events.pan, @e, {target: null})
-        assert(@spy_trigger.notCalled)
-
-      it "should trigger event if exists an active related tool", ->
-        gesture = new PanTool()
-        @plot.add_tools(gesture)
-
-        @ui_events._trigger(@ui_events.pan, @e, {target: null})
-
-        assert(@spy_trigger.calledOnce)
-        expect(@spy_trigger.args[0]).to.be.deep.equal([@ui_events.pan, @e, gesture.id])
-
-  describe "_bokify methods", ->
-
-    afterEach ->
-      @dom_stub.restore()
-      @spy.restore()
-
-    beforeEach ->
-      @dom_stub = sinon.stub(dom, "offset").returns({top: 0, left: 0})
-      @spy = sinon.spy(@plot, "trigger_event")
-
-    it "_bokify_hammer should trigger event with appropriate coords and model id", ->
-      e = new Event("tap") # XXX: <- this is not a hammer event
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      ev = @ui_events._tap_event(e)
-      @ui_events._trigger_bokeh_event(ev)
-
-      bk_event = @spy.args[0][0]
-
-      expect(bk_event).to.be.instanceof(Tap)
-      expect(bk_event.sx).to.be.equal(100)
-      expect(bk_event.sy).to.be.equal(200)
-      expect(bk_event.origin.id).to.be.equal(@plot.id)
-
-    it "_bokify_point_event should trigger event with appropriate coords and model id", ->
-      e = new Event("mousemove")
-      e.pageX = 100
-      e.pageY = 200
-
-      ev = @ui_events._move_event(e)
-      @ui_events._trigger_bokeh_event(ev)
-
-      bk_event = @spy.args[0][0]
-
-      expect(bk_event).to.be.instanceof(MouseMove)
-      expect(bk_event.sx).to.be.equal(100)
-      expect(bk_event.sy).to.be.equal(200)
-      expect(bk_event.origin.id).to.be.equal(@plot.id)
-
-  describe "_event methods", ->
-    ###
-    These tests are mildly integration tests. Based on an Event (as would be
-    initiated by event listeners attached in the _register_tool method), they
-    check whether the BokehEvent and UIEvents are correctly triggered.
-    ###
-
-    afterEach ->
-      @dom_stub.restore()
-      @spy_plot.restore()
-      @spy_uievent.restore()
-
-    beforeEach ->
-      @dom_stub = sinon.stub(dom, "offset").returns({top: 0, left: 0})
-      # The BokehEvent that is triggered by the plot
-      @spy_plot = sinon.spy(@plot, "trigger_event")
-      # The event is that triggered on UIEvent for tool interactions
-      @spy_uievent = sinon.spy(@plot_view.ui_event_bus, "trigger")
-
-    it "_tap method should handle tap event", ->
-      e = new Event("tap")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      @plot.add_tools(new TapTool())
-
-      @ui_events._tap(e)
-
-      expect(@spy_plot.callCount).to.be.equal(2) # tap event and selection event
-      assert(@spy_uievent.calledOnce)
-
-    it "_doubletap method should handle doubletap event", ->
-      e = new Event("doubletap")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      @plot.add_tools(new PolySelectTool())
-
-      @ui_events._doubletap(e)
-
-      expect(@spy_plot.callCount).to.be.equal(2) # tap event and selection event
-      assert(@spy_uievent.calledOnce)
-
-    it "_press method should handle press event", ->
-      e = new Event("press")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      @ui_events._press(e)
-
-      assert(@spy_plot.calledOnce)
-      # There isn't a tool that uses the _press method
-      # assert(@spy_uievent.calledOnce)
-
-    it "_pan_start method should handle panstart event", ->
-      e = new Event("panstart")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      pan_tool = new PanTool()
-      @plot.add_tools(pan_tool)
-
-      @ui_events._pan_start(e)
-
-      assert(@spy_plot.called)
-      assert(@spy_uievent.calledOnce)
-
-    it "_pan method should handle pan event", ->
-      e = new Event("pan")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      pan_tool = new PanTool()
-      @plot.add_tools(pan_tool)
-
-      @ui_events._pan(e)
-
-      assert(@spy_plot.called)
-      assert(@spy_uievent.calledOnce)
-
-    it "_pan_end method should handle pan end event", ->
-      e = new Event("panend")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      pan_tool = new PanTool()
-      @plot.add_tools(pan_tool)
-
-      @ui_events._pan_end(e)
-
-      assert(@spy_plot.calledOnce)
-      assert(@spy_uievent.calledOnce)
-
-    it "_pinch_start method should handle pinchstart event", ->
-      e = new Event("pinchstart")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      wheel_zoom_tool = new WheelZoomTool()
-      @plot.add_tools(wheel_zoom_tool)
-
-      #idk why it's not auto active
-      @plot.toolbar.gestures['pinch'].active = wheel_zoom_tool
-
-      @ui_events._pinch_start(e)
-
-      assert(@spy_plot.calledOnce)
-      # wheelzoomtool doesn't have _pinch_start but will emit event anyway
-      assert(@spy_uievent.calledOnce)
-
-    it "_pinch method should handle pinch event", ->
-      e = new Event("pinch")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      wheel_zoom_tool = new WheelZoomTool()
-      @plot.add_tools(wheel_zoom_tool)
-
-      #idk why it's not auto active
-      @plot.toolbar.gestures['pinch'].active = wheel_zoom_tool
-
-      @ui_events._pinch(e)
-
-      assert(@spy_plot.calledOnce)
-      assert(@spy_uievent.calledOnce)
-
-    it "_pinch_end method should handle pinchend event", ->
-      e = new Event("pinchend")
-      e.pointerType = "mouse"
-      e.srcEvent = {pageX: 100, pageY: 200}
-
-      wheel_zoom_tool = new WheelZoomTool()
-      @plot.add_tools(wheel_zoom_tool)
-
-      #idk why it's not auto active
-      @plot.toolbar.gestures['pinch'].active = wheel_zoom_tool
-
-      @ui_events._pinch_end(e)
-
-      assert(@spy_plot.calledOnce)
-      # wheelzoomtool doesn't have _pinch_start but will emit event anyway
-      assert(@spy_uievent.calledOnce)
-
-    # not implemented as tool method or BokehEvent
-    # it "_rotate_start method should handle rotatestart event", ->
-
-    # not implemented as tool method or BokehEvent
-    # it "_rotate method should handle rotate event", ->
-
-    # not implemented as tool method or BokehEvent
-    # it "_rotate_end method should handle rotateend event", ->
-
-    it "_move_enter method should handle mouseenter event", ->
-      e = new Event("mouseenter")
-
-      crosshair_tool = new CrosshairTool()
-      @plot.add_tools(crosshair_tool)
-
-      @ui_events._mouse_enter(e)
-
-      assert(@spy_plot.calledOnce)
-      assert(@spy_uievent.calledOnce)
-
-    it "_move method should handle mousemove event", ->
-      e = new Event("mousemove")
-
-      crosshair_tool = new CrosshairTool()
-      @plot.add_tools(crosshair_tool)
-
-      @ui_events._mouse_move(e)
-
-      assert(@spy_plot.calledOnce)
-      assert(@spy_uievent.calledOnce)
-
-    it "_move_exit method should handle mouseleave event", ->
-      e = new Event("mouseleave")
-
-      crosshair_tool = new CrosshairTool()
-      @plot.add_tools(crosshair_tool)
-
-      @ui_events._mouse_exit(e)
-
-      assert(@spy_plot.calledOnce)
-      assert(@spy_uievent.calledOnce)
-
-    it "_mouse_wheel method should handle wheel event", ->
-      e = new Event("wheel")
-
-      wheel_zoom_tool = new WheelZoomTool()
-      @plot.add_tools(wheel_zoom_tool)
-
-      #idk why it's not auto active
-      @plot.toolbar.gestures['scroll'].active = wheel_zoom_tool
-
-      @ui_events._mouse_wheel(e)
-
-      assert(@spy_plot.called)
-      assert(@spy_uievent.calledOnce)
-
-    # not implemented as tool method or BokehEvent
-    # it "_key_down method should handle keydown event", ->
-
-    it "_key_up method should handle keyup event", ->
-      e = new Event("keyup")
-
-      poly_select_tool = new PolySelectTool()
-      @plot.add_tools(poly_select_tool)
-
-      @ui_events._key_up(e)
-
-      # There isn't a BokehEvent model for keydown events
-      # assert(@spy_plot.calledOnce)
-      # This is a event on select tools that should probably be removed
-      assert(@spy_uievent.calledOnce)
-
-    it "multi-gesture tool should receive multiple events", ->
-      class MultiToolView extends SelectToolView
-        _tap: (e) ->
-        _pan: (e) ->
-
-      class MultiTool extends SelectTool
-        default_view: MultiToolView
-        type: "MultiTool"
-        tool_name: "Multi Tool"
-        event_type: ["tap", "pan"]
-
-      tool = new MultiTool()
-      @plot.add_tools(tool)
-      tool.active = true
-
-      etap = new Event("tap")
-      etap.pointerType = "mouse"
-      etap.srcEvent = {pageX: 100, pageY: 200}
-
-      @ui_events._tap(etap)
-      assert(@spy_uievent.calledOnce, "Tap event not triggered")
-
-      epan = new Event("pan")
-      epan.pointerType = "mouse"
-      epan.srcEvent = {pageX: 100, pageY: 200}
-      @ui_events._pan(epan)
-      assert(@spy_uievent.calledTwice, "Pan event not triggered")
diff --git a/bokehjs/test/core/ui_events.ts b/bokehjs/test/core/ui_events.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/core/ui_events.ts
@@ -0,0 +1,565 @@
+import {expect, assert} from "chai"
+import * as sinon from 'sinon'
+
+import * as dom from "core/dom"
+import {Tap, MouseMove} from "core/bokeh_events"
+
+import {CrosshairTool} from "models/tools/inspectors/crosshair_tool"
+import {PanTool} from "models/tools/gestures/pan_tool"
+import {PolySelectTool} from "models/tools/gestures/poly_select_tool"
+import {SelectTool, SelectToolView} from "models/tools/gestures/select_tool"
+import {TapTool} from "models/tools/gestures/tap_tool"
+import {WheelZoomTool} from "models/tools/gestures/wheel_zoom_tool"
+
+import {Legend} from "models/annotations/legend"
+import {Plot, PlotView} from "models/plots/plot"
+import {Range1d} from "models/ranges/range1d"
+import {UIEvents, UIEvent, GestureEvent, TapEvent} from "core/ui_events"
+
+describe("ui_events module", () => {
+
+  function new_plot(): PlotView {
+    const plot = new Plot({
+      x_range: new Range1d({start: 0, end: 1}),
+      y_range: new Range1d({start: 0, end: 1}),
+    })
+    return new plot.default_view({model: plot, parent: null}).build()
+  }
+
+  let hammer_stub: sinon.SinonStub
+  let plot_view: PlotView
+  let ui_events: UIEvents
+  let ANY_ui_events: any
+
+  beforeEach(() => {
+    hammer_stub = sinon.stub(UIEvents.prototype as any, "_configure_hammerjs") // XXX: protected
+
+    plot_view = new_plot()
+    ui_events = (plot_view as any).ui_event_bus // XXX: protected
+    ANY_ui_events = ui_events // XXX: protected
+  })
+
+  afterEach(() => {
+    hammer_stub.restore()
+  })
+
+  describe("_trigger method", () => {
+
+    let spy_trigger: sinon.SinonSpy
+
+    beforeEach(() => {
+      spy_trigger = sinon.spy(ui_events, "trigger")
+    })
+
+    afterEach(() => {
+      spy_trigger.restore()
+    })
+
+    describe("base_type=move", () => {
+
+      let e: UIEvent
+      let spy_cursor: sinon.SinonSpy
+      beforeEach(() => {
+        e = {type: "mousemove", sx: 0, sy: 0}
+        spy_cursor = sinon.spy(plot_view, "set_cursor")
+      })
+
+      afterEach(() => {
+        spy_cursor.restore()
+      })
+
+      it("should trigger move event for active inspectors", () => {
+        const inspector = new CrosshairTool({active: true})
+        plot_view.model.add_tools(inspector)
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+
+        assert(spy_trigger.calledOnce)
+        expect(spy_trigger.args[0]).to.be.deep.equal([ui_events.move, e, inspector.id])
+      })
+
+      it("should not trigger move event for inactive inspectors", () => {
+        const inspector = new CrosshairTool({active: false})
+        plot_view.model.add_tools(inspector)
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+
+        assert(spy_trigger.notCalled)
+      })
+
+      it("should use default cursor no active inspector", () => {
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+
+        assert(spy_cursor.calledOnce)
+        assert(spy_cursor.calledWith("default"))
+      })
+
+      it("should use default cursor if active inspector but mouse is off-frame", () => {
+        const inspector = new CrosshairTool()
+        plot_view.model.add_tools(inspector)
+
+        const ss = sinon.stub(ui_events as any, "_hit_test_frame").returns(false) // XXX: protected
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+        assert(spy_cursor.calledOnce)
+        assert(spy_cursor.calledWith("default"))
+
+        ss.restore()
+      })
+
+      it("should change cursor if active inspector is present and over frame", () => {
+        const inspector = new CrosshairTool()
+        plot_view.model.add_tools(inspector)
+
+        const ss = sinon.stub(ui_events as any, "_hit_test_frame").returns(true) // XXX: protected
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+        assert(spy_cursor.calledOnce)
+        assert(spy_cursor.calledWith("crosshair"))
+
+        ss.restore()
+      })
+
+      it("should change cursor on view_renderer with cursor method", () => {
+        const legend = new Legend({click_policy: "mute"})
+        const legend_view = new legend.default_view({model: legend, parent: plot_view})
+
+        const ss = sinon.stub(ui_events as any, "_hit_test_renderers").returns(legend_view) // XXX: protected
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+        assert(spy_cursor.calledOnce)
+        assert(spy_cursor.calledWith("pointer"))
+
+        ss.restore()
+      })
+
+      it("should override event_type if active inspector clashes with view renderer", () => {
+        const inspector = new CrosshairTool()
+        plot_view.model.add_tools(inspector)
+
+        const legend = new Legend({click_policy: "mute"})
+        const legend_view = new legend.default_view({model: legend, parent: plot_view})
+
+        const ss = sinon.stub(ui_events as any, "_hit_test_renderers").returns(legend_view) // XXX: protected
+
+        ui_events._trigger(ui_events.move, e, new Event("mousemove"))
+        assert(spy_trigger.calledOnce)
+        expect(spy_trigger.args[0]).to.be.deep.equal([ui_events.move_exit, e, inspector.id])
+        // should also use view renderer cursor and not inspector cursor
+        assert(spy_cursor.calledOnce)
+        assert(spy_cursor.calledWith("pointer"))
+
+        ss.restore()
+      })
+    })
+
+    describe("base_type=tap", () => {
+
+      let e: UIEvent
+      beforeEach(() => {
+        e = {type: "tap", sx: 10, sy: 15, shiftKey: false}
+      })
+
+      it("should not trigger tap event if no active tap tool", () => {
+        ui_events._trigger(ui_events.tap, e, new Event("mousemove"))
+        assert(spy_trigger.notCalled)
+      })
+
+      it("should trigger tap event if exists an active tap tool", () => {
+        const gesture = new TapTool()
+        plot_view.model.add_tools(gesture)
+
+        ui_events._trigger(ui_events.tap, e, new Event("mousemove"))
+
+        assert(spy_trigger.calledOnce)
+        expect(spy_trigger.args[0]).to.be.deep.equal([ui_events.tap, e, gesture.id])
+      })
+
+      it("should call on_hit method on view renderer if exists", () => {
+        const legend = new Legend({click_policy: "mute"})
+        const legend_view = new legend.default_view({model: legend, parent: plot_view})
+
+        const ss = sinon.stub(ui_events as any, "_hit_test_renderers").returns(legend_view) // XXX: protected
+        const on_hit = sinon.stub(legend_view, "on_hit")
+
+        ui_events._trigger(ui_events.tap, e, new Event("mousemove"))
+        assert(on_hit.calledOnce)
+        expect(on_hit.args[0]).to.be.deep.equal([10, 15])
+
+        on_hit.restore()
+        ss.restore()
+      })
+    })
+
+    describe("base_type=scroll", () => {
+
+      let e: UIEvent
+      let srcEvent: Event
+      let preventDefault: sinon.SinonSpy
+      let stopPropagation: sinon.SinonSpy
+
+      beforeEach(() => {
+        e = {type: "wheel", sx: 0, sy: 0, delta: 1}
+        srcEvent = new Event("scroll")
+
+        preventDefault = sinon.spy(srcEvent, "preventDefault")
+        stopPropagation = sinon.spy(srcEvent, "stopPropagation")
+      })
+
+      afterEach(() => {
+        preventDefault.restore()
+        stopPropagation.restore()
+      })
+
+      it("should not trigger scroll event if no active scroll tool", () => {
+        plot_view.model.toolbar.gestures.scroll.active = null
+        ui_events._trigger(ui_events.scroll, e, srcEvent)
+        assert(spy_trigger.notCalled)
+
+        // assert that default scrolling isn't hijacked
+        assert(preventDefault.notCalled)
+        assert(stopPropagation.notCalled)
+      })
+
+      it("should trigger scroll event if exists an active tap tool", () => {
+        const gesture = new WheelZoomTool()
+        plot_view.model.add_tools(gesture)
+        // unclear why add_tools doesn't activate the tool, so have to do it manually
+        plot_view.model.toolbar.gestures.scroll.active = gesture
+
+        ui_events._trigger(ui_events.scroll, e, srcEvent)
+
+        // assert that default scrolling is disabled
+        assert(preventDefault.calledOnce)
+        assert(stopPropagation.calledOnce)
+
+        assert(spy_trigger.calledOnce)
+        expect(spy_trigger.args[0]).to.be.deep.equal([ui_events.scroll, e, gesture.id])
+      })
+    })
+
+    describe("normally propagate other gesture base_types", () => {
+
+      let e: UIEvent
+      beforeEach(() => {
+        e = {type: "pan", sx: 0, sy: 0, deltaX: 0, deltaY: 0, scale: 1, shiftKey: false}
+      })
+
+      it("should not trigger event if no active tool", () => {
+        ui_events._trigger(ui_events.pan, e, new Event("pointerdown"))
+        assert(spy_trigger.notCalled)
+      })
+
+      it("should trigger event if exists an active related tool", () => {
+        const gesture = new PanTool()
+        plot_view.model.add_tools(gesture)
+
+        ui_events._trigger(ui_events.pan, e, new Event("pointerdown"))
+
+        assert(spy_trigger.calledOnce)
+        expect(spy_trigger.args[0]).to.be.deep.equal([ui_events.pan, e, gesture.id])
+      })
+    })
+  })
+
+  describe("_bokify methods", () => {
+
+    let dom_stub: sinon.SinonStub
+    let spy: sinon.SinonSpy
+
+    beforeEach(() => {
+      dom_stub = sinon.stub(dom, "offset").returns({top: 0, left: 0})
+      spy = sinon.spy(plot_view.model, "trigger_event")
+    })
+
+    afterEach(() => {
+      dom_stub.restore()
+      spy.restore()
+    })
+
+    it("_bokify_hammer should trigger event with appropriate coords and model id", () => {
+      const e: any = new Event("tap") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const ev = ANY_ui_events._tap_event(e)
+      ANY_ui_events._trigger_bokeh_event(ev)
+
+      const bk_event = spy.args[0][0]
+
+      expect(bk_event).to.be.instanceof(Tap)
+      expect(bk_event.sx).to.be.equal(100)
+      expect(bk_event.sy).to.be.equal(200)
+      // XXX: expect(bk_event.origin.id).to.be.equal(plot_view.model.id)
+    })
+
+    it("_bokify_point_event should trigger event with appropriate coords and model id", () => {
+      const e: any = new Event("mousemove")
+      e.pageX = 100 // XXX: readonly
+      e.pageY = 200 // XXX: readonly
+
+      const ev = ANY_ui_events._move_event(e)
+      ANY_ui_events._trigger_bokeh_event(ev)
+
+      const bk_event = spy.args[0][0]
+
+      expect(bk_event).to.be.instanceof(MouseMove)
+      expect(bk_event.sx).to.be.equal(100)
+      expect(bk_event.sy).to.be.equal(200)
+      // XXX: expect(bk_event.origin.id).to.be.equal(plot_view.model.id)
+    })
+  })
+
+  describe("_event methods", () => {
+    // These tests are mildly integration tests. Based on an Event (as would be
+    // initiated by event listeners attached in the _register_tool method), they
+    // check whether the BokehEvent and UIEvents are correctly triggered.
+
+    let dom_stub: sinon.SinonStub
+    let spy_plot: sinon.SinonSpy
+    let spy_uievent: sinon.SinonSpy
+
+    beforeEach(() => {
+      dom_stub = sinon.stub(dom, "offset").returns({top: 0, left: 0})
+      // The BokehEvent that is triggered by the plot
+      spy_plot = sinon.spy(plot_view.model, "trigger_event")
+      // The event is that triggered on UIEvent for tool interactions
+      spy_uievent = sinon.spy(ui_events, "trigger")
+    })
+
+    afterEach(() => {
+      dom_stub.restore()
+      spy_plot.restore()
+      spy_uievent.restore()
+    })
+
+    it("_tap method should handle tap event", () => {
+      const e: any = new Event("tap") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      plot_view.model.add_tools(new TapTool())
+
+      ANY_ui_events._tap(e)
+
+      expect(spy_plot.callCount).to.be.equal(2) // tap event and selection event
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_doubletap method should handle doubletap event", () => {
+      const e: any = new Event("doubletap") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      plot_view.model.add_tools(new PolySelectTool())
+
+      ANY_ui_events._doubletap(e)
+
+      expect(spy_plot.callCount).to.be.equal(2) // tap event and selection event
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_press method should handle press event", () => {
+      const e: any = new Event("press") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      ANY_ui_events._press(e)
+
+      assert(spy_plot.calledOnce)
+      // There isn't a tool that uses the _press method
+      // assert(spy_uievent.calledOnce)
+    })
+
+    it("_pan_start method should handle panstart event", () => {
+      const e: any = new Event("panstart") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const pan_tool = new PanTool()
+      plot_view.model.add_tools(pan_tool)
+
+      ANY_ui_events._pan_start(e)
+
+      assert(spy_plot.called)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_pan method should handle pan event", () => {
+      const e: any = new Event("pan") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const pan_tool = new PanTool()
+      plot_view.model.add_tools(pan_tool)
+
+      ANY_ui_events._pan(e)
+
+      assert(spy_plot.called)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_pan_end method should handle pan end event", () => {
+      const e: any = new Event("panend") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const pan_tool = new PanTool()
+      plot_view.model.add_tools(pan_tool)
+
+      ANY_ui_events._pan_end(e)
+
+      assert(spy_plot.calledOnce)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_pinch_start method should handle pinchstart event", () => {
+      const e: any = new Event("pinchstart") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const wheel_zoom_tool = new WheelZoomTool()
+      plot_view.model.add_tools(wheel_zoom_tool)
+
+      //idk why it's not auto active
+      plot_view.model.toolbar.gestures.pinch.active = wheel_zoom_tool
+
+      ANY_ui_events._pinch_start(e)
+
+      assert(spy_plot.calledOnce)
+      // wheelzoomtool doesn't have _pinch_start but will emit event anyway
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_pinch method should handle pinch event", () => {
+      const e: any = new Event("pinch") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const wheel_zoom_tool = new WheelZoomTool()
+      plot_view.model.add_tools(wheel_zoom_tool)
+
+      //idk why it's not auto active
+      plot_view.model.toolbar.gestures.pinch.active = wheel_zoom_tool
+
+      ANY_ui_events._pinch(e)
+
+      assert(spy_plot.calledOnce)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_pinch_end method should handle pinchend event", () => {
+      const e: any = new Event("pinchend") // XXX: not a hammerjs event
+      e.pointerType = "mouse"
+      e.srcEvent = {pageX: 100, pageY: 200}
+
+      const wheel_zoom_tool = new WheelZoomTool()
+      plot_view.model.add_tools(wheel_zoom_tool)
+
+      //idk why it's not auto active
+      plot_view.model.toolbar.gestures.pinch.active = wheel_zoom_tool
+
+      ANY_ui_events._pinch_end(e)
+
+      assert(spy_plot.calledOnce)
+      // wheelzoomtool doesn't have _pinch_start but will emit event anyway
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_move_enter method should handle mouseenter event", () => {
+      const e = new Event("mouseenter")
+
+      const crosshair_tool = new CrosshairTool()
+      plot_view.model.add_tools(crosshair_tool)
+
+      ANY_ui_events._mouse_enter(e)
+
+      assert(spy_plot.calledOnce)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_move method should handle mousemove event", () => {
+      const e = new Event("mousemove")
+
+      const crosshair_tool = new CrosshairTool()
+      plot_view.model.add_tools(crosshair_tool)
+
+      ANY_ui_events._mouse_move(e)
+
+      assert(spy_plot.calledOnce)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_move_exit method should handle mouseleave event", () => {
+      const e = new Event("mouseleave")
+
+      const crosshair_tool = new CrosshairTool()
+      plot_view.model.add_tools(crosshair_tool)
+
+      ANY_ui_events._mouse_exit(e)
+
+      assert(spy_plot.calledOnce)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_mouse_wheel method should handle wheel event", () => {
+      const e = new Event("wheel")
+
+      const wheel_zoom_tool = new WheelZoomTool()
+      plot_view.model.add_tools(wheel_zoom_tool)
+
+      //idk why it's not auto active
+      plot_view.model.toolbar.gestures.scroll.active = wheel_zoom_tool
+
+      ANY_ui_events._mouse_wheel(e)
+
+      assert(spy_plot.called)
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("_key_up method should handle keyup event", () => {
+      const e = new Event("keyup")
+
+      const poly_select_tool = new PolySelectTool()
+      plot_view.model.add_tools(poly_select_tool)
+
+      ANY_ui_events._key_up(e)
+
+      // There isn't a BokehEvent model for keydown events
+      // assert(spy_plot.calledOnce)
+      // This is a event on select tools that should probably be removed
+      assert(spy_uievent.calledOnce)
+    })
+
+    it("multi-gesture tool should receive multiple events", () => {
+      class MultiToolView extends SelectToolView {
+        _tap(_e: TapEvent): void {}
+        _pan(_e: GestureEvent): void {}
+      }
+
+      class MultiTool extends SelectTool {
+        default_view = MultiToolView
+        type = "MultiTool"
+        tool_name = "Multi Tool"
+        event_type = ["tap" as "tap", "pan" as "pan"]
+      }
+
+      const tool = new MultiTool()
+      plot_view.model.add_tools(tool)
+      tool.active = true
+
+      const etap: any = new Event("tap") // XXX: not a hammerjs event
+      etap.pointerType = "mouse"
+      etap.srcEvent = {pageX: 100, pageY: 200}
+
+      ANY_ui_events._tap(etap)
+      assert(spy_uievent.calledOnce, "Tap event not triggered")
+
+      const epan: any = new Event("pan") // XXX: not a hammerjs event
+      epan.pointerType = "mouse"
+      epan.srcEvent = {pageX: 100, pageY: 200}
+      ANY_ui_events._pan(epan)
+      assert(spy_uievent.calledTwice, "Pan event not triggered")
+    })
+  })
+})
diff --git a/bokehjs/test/document/document.coffee b/bokehjs/test/document/document.coffee
deleted file mode 100644
--- a/bokehjs/test/document/document.coffee
+++ /dev/null
@@ -1,798 +0,0 @@
-{expect} = require "chai"
-sinon = require "sinon"
-{ stdoutTrap, stderrTrap } = require 'logtrap'
-
-{values, size} = require("core/util/object")
-{Document, ModelChangedEvent, TitleChangedEvent, RootAddedEvent, RootRemovedEvent, DEFAULT_TITLE} = require "document"
-js_version = require("version").version
-{Models} = require "base"
-{Model} = require "model"
-logging = require "core/logging"
-p = require "core/properties"
-
-class AnotherModel extends Model
-  type: 'AnotherModel'
-
-  @define {
-    bar: [ p.Number, 1 ]
-  }
-
-Models.register('AnotherModel', AnotherModel)
-
-class SomeModel extends Model
-  type: 'SomeModel'
-
-  @define {
-    foo:   [ p.Number, 2 ]
-    child: [ p.Instance, null ]
-  }
-
-Models.register('SomeModel', SomeModel)
-
-class SomeModelWithChildren extends Model
-  type: 'SomeModelWithChildren'
-
-  @define {
-    children: [ p.Array, [] ]
-  }
-
-Models.register('SomeModelWithChildren', SomeModelWithChildren)
-
-class ModelWithConstructTimeChanges extends Model
-  type: 'ModelWithConstructTimeChanges'
-
-  initialize: (attributes, options) ->
-    super(attributes, options)
-    @foo = 4
-    @child = new AnotherModel()
-
-  @define {
-    foo:   [ p.Number, 2 ]
-    child: [ p.Instance, null ]
-  }
-
-Models.register('ModelWithConstructTimeChanges', ModelWithConstructTimeChanges)
-
-class ComplicatedModelWithConstructTimeChanges extends Model
-  type: 'ComplicatedModelWithConstructTimeChanges'
-
-  initialize: (attributes, options) ->
-    super(attributes, options)
-    @list_prop = [new AnotherModel()]
-    @dict_prop = { foo: new AnotherModel() }
-    @obj_prop = new ModelWithConstructTimeChanges()
-    @dict_of_list_prop = { foo: [new AnotherModel()] }
-
-  @define {
-    list_prop:         [ p.Array ]
-    dict_prop:         [ p.Any ]
-    obj_prop:          [ p.Instance ]
-    dict_of_list_prop: [ p.Any ]
-  }
-
-Models.register('ComplicatedModelWithConstructTimeChanges', ComplicatedModelWithConstructTimeChanges)
-
-describe "Document", ->
-
-  it "should be constructable", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-
-  it "has working add_root", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    d.add_root(new AnotherModel())
-    expect(d.roots().length).to.equal 1
-
-  it "manages noting interactivity periods", sinon.test () ->
-    d = new Document()
-    expect(d._interactive_plot).to.be.null
-    expect(d._interactive_timestamp).to.be.null
-    expect(d.interactive_duration()).to.equal -1
-    stub = sinon.stub(Date, 'now')
-    stub.onCall(0).returns(10);
-    stub.onCall(1).returns(12);
-    stub.onCall(2).returns(15);
-    stub.onCall(3).returns(18);
-
-    m1 = new SomeModel()
-    m2 = new AnotherModel()
-
-    d.interactive_start(m1)  # first stub value 10
-    expect(d._interactive_plot.id).to.equal m1.id
-    expect(d._interactive_timestamp).to.be.equal 10
-    expect(d.interactive_duration()).to.be.equal 2 # second stub value 12
-
-    d.interactive_start(m2)  # third stub value 15
-    expect(d._interactive_plot.id).to.equal m1.id
-    expect(d._interactive_timestamp).to.be.equal 15
-    expect(d.interactive_duration()).to.be.equal 3 # second stub value 18
-
-    d.interactive_stop(m1)
-    expect(d._interactive_plot).to.be.null
-    expect(d._interactive_timestamp).to.be.null
-    expect(d.interactive_duration()).to.equal -1
-
-  it "has working set_title", ->
-    d = new Document()
-    expect(d.title()).to.equal "Bokeh Application"
-    d.set_title("Foo")
-    expect(d.title()).to.equal "Foo"
-
-  it "tracks all_models", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-    m = new SomeModel()
-    m2 = new AnotherModel()
-    m.child = m2
-    expect(m.child).to.equal m2
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(Object.keys(d._all_models).length).to.equal 2
-
-    m.child = null
-    expect(Object.keys(d._all_models).length).to.equal 1
-    m.child = m2
-    expect(Object.keys(d._all_models).length).to.equal 2
-    d.remove_root(m)
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-  it "tracks all_models with list property", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-    m = new SomeModelWithChildren()
-    m2 = new AnotherModel()
-    m.children = [m2]
-    expect(m.children).to.deep.equal [ m2 ]
-    # check that we get the right all_models on initial add_root
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(Object.keys(d._all_models).length).to.equal 2
-
-    # check that removing children list drops the models beneath it
-    m.children = []
-    expect(Object.keys(d._all_models).length).to.equal 1
-
-    # check that adding children back re-adds the models
-    m.children = [m2]
-    expect(Object.keys(d._all_models).length).to.equal 2
-
-    # check that removing root removes the models
-    d.remove_root(m)
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-  it "tracks all_models with list property where list elements have a child", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-    m = new SomeModelWithChildren()
-    m3 = new AnotherModel()
-    m2 = new SomeModel({ child: m3 })
-    m.children = [m2]
-    expect(m.children).to.deep.equal [ m2 ]
-
-    # check that we get the right all_models on initial add_root
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(Object.keys(d._all_models).length).to.equal 3
-
-    # check that removing children list drops the models beneath it
-    m.children = []
-    expect(Object.keys(d._all_models).length).to.equal 1
-
-    # check that adding children back re-adds the models
-    m.children = [m2]
-    expect(Object.keys(d._all_models).length).to.equal 3
-
-    # check that removing root removes the models
-    d.remove_root(m)
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-  it "lets us get_model_by_id", ->
-    d = new Document()
-    m = new SomeModel()
-    m2 = new AnotherModel()
-    m.child = m2
-    d.add_root(m)
-    expect(d.get_model_by_id(m.id)).to.equal(m)
-    expect(d.get_model_by_id(m2.id)).to.equal(m2)
-    expect(d.get_model_by_id("invalidid")).to.equal(null)
-
-  it "lets us get_model_by_name", ->
-    d = new Document()
-    m = new SomeModel({ name : "foo" })
-    m2 = new AnotherModel({ name : "bar" })
-    m.child = m2
-    d.add_root(m)
-    expect(d.get_model_by_name(m.name)).to.equal(m)
-    expect(d.get_model_by_name(m2.name)).to.equal(m2)
-    expect(d.get_model_by_name("invalidid")).to.equal(null)
-
-  it "lets us get_model_by_name after changing name", ->
-    d = new Document()
-    m = new SomeModel({ name : "foo" })
-    d.add_root(m)
-    expect(d.get_model_by_name("foo")).to.equal(m)
-    expect(d.get_model_by_name("bar")).to.equal(null)
-    m.name = "bar"
-    expect(d.get_model_by_name("foo")).to.equal(null)
-    expect(d.get_model_by_name("bar")).to.equal(m)
-
-  it "throws on get_model_by_name with duplicate name", ->
-    d = new Document()
-    m = new SomeModel({ name : "foo" })
-    m2 = new AnotherModel({ name : "foo" })
-    d.add_root(m)
-    d.add_root(m2)
-    got_error = false
-    try
-      d.get_model_by_name('foo')
-    catch e
-      got_error = true
-      expect(e.message).to.include('Multiple models')
-    expect(got_error).to.equal(true)
-
-  it "can have all_models with multiple references", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(size(d._all_models)).to.equal 0
-
-    root1 = new SomeModel()
-    root2 = new SomeModel()
-    child1 = new AnotherModel()
-    root1.child = child1
-    root2.child = child1
-    d.add_root(root1)
-    d.add_root(root2)
-    expect(d.roots().length).to.equal 2
-    expect(size(d._all_models)).to.equal 3
-
-    root1.child = null
-    expect(size(d._all_models)).to.equal 3
-
-    root2.child = null
-    expect(size(d._all_models)).to.equal 2
-
-    root1.child = child1
-    expect(size(d._all_models)).to.equal 3
-
-    root2.child = child1
-    expect(size(d._all_models)).to.equal 3
-
-    d.remove_root(root1)
-    expect(size(d._all_models)).to.equal 2
-
-    d.remove_root(root2)
-    expect(size(d._all_models)).to.equal 0
-
-  it "can have all_models with cycles", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(size(d._all_models)).to.equal 0
-
-    root1 = new SomeModel()
-    root2 = new SomeModel()
-    child1 = new SomeModel()
-    root1.child = child1
-    root2.child = child1
-    child1.child = root1
-    d.add_root(root1)
-    d.add_root(root2)
-    expect(d.roots().length).to.equal 2
-    expect(size(d._all_models)).to.equal 3
-
-    root1.child = null
-    expect(size(d._all_models)).to.equal 3
-
-    root2.child = null
-    expect(size(d._all_models)).to.equal 2
-
-    root1.child = child1
-    expect(size(d._all_models)).to.equal 3
-
-  it "can have all_models with cycles through lists", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(size(d._all_models)).to.equal 0
-
-    root1 = new SomeModelWithChildren()
-    root2 = new SomeModelWithChildren()
-    child1 = new SomeModelWithChildren()
-    root1.children = [child1]
-    root2.children = [child1]
-    child1.children = [root1]
-    d.add_root(root1)
-    d.add_root(root2)
-    expect(d.roots().length).to.equal 2
-    expect(size(d._all_models)).to.equal 3
-
-    root1.children = []
-    expect(size(d._all_models)).to.equal 3
-
-    root2.children = []
-    expect(size(d._all_models)).to.equal 2
-
-    root1.children = [child1]
-    expect(size(d._all_models)).to.equal 3
-
-  it "can notify on changes", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-
-    m = new AnotherModel()
-
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(m.bar).to.equal 1
-
-    events = []
-    curdoc_from_listener = []
-    listener = (event) ->
-      events.push(event)
-    d.on_change(listener)
-
-    m.bar = 42
-    expect(events.length).to.equal 1
-    expect(events[0]).is.instanceof ModelChangedEvent
-    expect(events[0].document).to.equal d
-    expect(events[0].model).to.equal m
-    expect(events[0].attr).to.equal 'bar'
-    expect(events[0].old).to.equal 1
-    expect(events[0].new_).to.equal 42
-
-  it "can remove notification changes", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-
-    m = new AnotherModel()
-
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(m.bar).to.equal 1
-
-    events = []
-    listener = (event) ->
-      events.push(event)
-    d.on_change(listener)
-
-    m.bar = 42
-
-    expect(events.length).to.equal 1
-    expect(events[0].new_).to.equal 42
-
-    d.remove_on_change(listener)
-    m.bar = 43
-
-    expect(events.length).to.equal 1
-
-  it "should notify on roots change", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-
-    events = []
-    listener = (event) ->
-      events.push(event)
-    d.on_change(listener)
-
-    m = new AnotherModel({bar:1})
-    d.add_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(events.length).to.equal 1
-    expect(events[0]).is.instanceof RootAddedEvent
-    expect(events[0].model).to.equal m
-
-    m2 = new AnotherModel({bar:2})
-    d.add_root(m2)
-    expect(d.roots().length).to.equal 2
-    expect(events.length).to.equal 2
-    expect(events[1]).is.instanceof RootAddedEvent
-    expect(events[1].model).to.equal m2
-
-    d.remove_root(m)
-    expect(d.roots().length).to.equal 1
-    expect(events.length).to.equal 3
-    expect(events[2]).is.instanceof RootRemovedEvent
-    expect(events[2].model).to.equal m
-
-    d.remove_root(m2)
-    expect(d.roots().length).to.equal 0
-    expect(events.length).to.equal 4
-    expect(events[3]).is.instanceof RootRemovedEvent
-    expect(events[3].model).to.equal m2
-
-  it "should notify on title change", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(d.title()).to.equal DEFAULT_TITLE
-
-    events = []
-    listener = (event) ->
-      events.push(event)
-    d.on_change(listener)
-
-    d.set_title('Foo')
-    expect(d.title()).to.equal 'Foo'
-    expect(events.length).to.equal 1
-    expect(events[0]).is.instanceof TitleChangedEvent
-    expect(events[0].document).to.equal d
-    expect(events[0].title).to.equal 'Foo'
-
-  it "can clear", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(d.title()).to.equal DEFAULT_TITLE
-    d.add_root(new AnotherModel())
-    d.add_root(new AnotherModel())
-    d.set_title('Foo')
-    expect(d.roots().length).to.equal 2
-    expect(d.title()).to.equal 'Foo'
-    d.clear()
-    expect(d.roots().length).to.equal 0
-    expect(size(d._all_models)).to.equal 0
-    # does not reset title
-    expect(d.title()).to.equal 'Foo'
-
-  it "throws on destructive move of itself", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(d.title()).to.equal DEFAULT_TITLE
-    d.add_root(new AnotherModel())
-    d.add_root(new AnotherModel())
-    d.set_title('Foo')
-    expect(d.roots().length).to.equal 2
-    expect(d.title()).to.equal 'Foo'
-    try
-      d.destructively_move(d)
-    catch e
-      got_error = true
-      expect(e.message).to.include('Attempted to overwrite a document with itself')
-    expect(got_error).to.equal(true)
-
-  it "can destructively move", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(d.title()).to.equal DEFAULT_TITLE
-    d.add_root(new AnotherModel())
-    d.add_root(new AnotherModel())
-    d.set_title('Foo')
-    expect(d.roots().length).to.equal 2
-    expect(d.title()).to.equal 'Foo'
-
-    d2 = new Document()
-    expect(d2.roots().length).to.equal 0
-    expect(d2.title()).to.equal DEFAULT_TITLE
-    d2.add_root(new SomeModel())
-    d2.set_title('Bar')
-    expect(d2.roots().length).to.equal 1
-    expect(d2.title()).to.equal 'Bar'
-
-    d2.destructively_move(d)
-    expect(d.roots().length).to.equal 1
-    expect(d.roots()[0].foo).to.equal 2
-    expect(d.title()).to.equal 'Bar'
-
-    expect(d2.roots().length).to.equal 0
-
-  it "checks for versions matching", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    root1 = new SomeModel()
-    d.add_root(root1)
-    expect(d.roots().length).to.equal 1
-    d.set_title("Foo")
-
-    old_log_level = logging.logger.level.name
-    logging.set_log_level("warn")
-    json = d.to_json_string()
-    parsed = JSON.parse(json)
-    parsed['version'] = "#{js_version}"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal ""
-
-    parsed['version'] = "0.0.1"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal "[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (#{js_version}) / Python (#{parsed["version"]})\n"
-
-    parsed['version'] = "#{js_version}rc123"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal "[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (#{js_version}) / Python (#{parsed["version"]})\n"
-
-    parsed['version'] = "#{js_version}dev123"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal "[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (#{js_version}) / Python (#{parsed["version"]})\n"
-
-    parsed['version'] = "#{js_version}-foo"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal ""
-
-    parsed['version'] = "#{js_version}rc123-foo"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal ""
-
-    parsed['version'] = "#{js_version}dev123-bar"
-    out = stderrTrap -> Document.from_json_string(JSON.stringify(parsed))
-    expect(out).to.be.equal ""
-
-    # need to reset old log level
-    logging.set_log_level(old_log_level)
-
-  it "can serialize with one model in it", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    root1 = new SomeModel()
-    d.add_root(root1)
-    expect(d.roots().length).to.equal 1
-    d.set_title("Foo")
-
-    json = d.to_json_string()
-    parsed = JSON.parse(json)
-    parsed['version'] = js_version
-    copy = Document.from_json_string(JSON.stringify(parsed))
-
-    expect(copy.roots().length).to.equal 1
-    expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
-    expect(copy.title()).to.equal "Foo"
-
-  it "can serialize excluding defaults", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    root1 = new SomeModel()
-    root1.name = 'bar'
-    d.add_root(root1)
-    expect(d.roots().length).to.equal 1
-
-    json = d.to_json_string(include_defaults=false)
-    parsed = JSON.parse(json)
-    parsed['version'] = js_version
-    copy = Document.from_json_string(JSON.stringify(parsed))
-
-    expect(copy.roots().length).to.equal 1
-    expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
-    expect(copy.roots()[0].name).to.be.equal 'bar'
-
-    # be sure defaults were NOT included
-    attrs = parsed['roots']['references'][0]['attributes']
-    expect('tags' of attrs).to.be.equal false
-    expect('foo' of attrs).to.be.equal false
-    expect('child' of attrs).to.be.equal false
-    # this should be included, non-default
-    expect('name' of attrs).to.be.equal true
-
-    # double-check different results if we do include_defaults
-    parsed_with_defaults = JSON.parse(d.to_json_string(include_defaults=true))
-    attrs = parsed_with_defaults['roots']['references'][0]['attributes']
-    #expect('tags' of attrs).to.be.equal true
-    expect('foo' of attrs).to.be.equal true
-    expect('child' of attrs).to.be.equal true
-    expect('name' of attrs).to.be.equal true
-
-  # TODO copy the following tests from test_document.py here
-  # TODO(havocp) test_serialization_more_models
-
-  it "can patch an integer property", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new SomeModel({ foo: 42 })
-    root2 = new SomeModel({ foo: 43 })
-    child1 = new SomeModel({ foo: 44 })
-    root1.setv({ child: child1 })
-    root2.setv({ child: child1 })
-    d.add_root(root1)
-    d.add_root(root2)
-    expect(d.roots().length).to.equal 2
-
-    event1 = new ModelChangedEvent(d, root1, 'foo', root1.foo, 57)
-    patch1 = d.create_json_patch_string([event1])
-    d.apply_json_patch(JSON.parse(patch1))
-
-    expect(root1.foo).to.equal 57
-
-    event2 = new ModelChangedEvent(d, child1, 'foo', child1.foo, 67)
-    patch2 = d.create_json_patch_string([event2])
-    d.apply_json_patch(JSON.parse(patch2))
-
-    expect(child1.foo).to.equal 67
-
-  it "can patch a reference property", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new SomeModel({ foo: 42 })
-    root2 = new SomeModel({ foo: 43 })
-    child1 = new SomeModel({ foo: 44 })
-    child2 = new SomeModel({ foo: 45 })
-    child3 = new SomeModel({ foo: 46, child: child2})
-    root1.setv({ child: child1 })
-    root2.setv({ child: child1 })
-    d.add_root(root1)
-    d.add_root(root2)
-    expect(d.roots().length).to.equal 2
-
-    expect(d._all_models).to.have.property(child1.id)
-    expect(d._all_models).to.not.have.property(child2.id)
-    expect(d._all_models).to.not.have.property(child3.id)
-
-    event1 = new ModelChangedEvent(d, root1, 'child', root1.child, child3)
-    patch1 = d.create_json_patch_string([event1])
-    d.apply_json_patch(JSON.parse(patch1))
-
-    expect(root1.child.id).to.equal child3.id
-    expect(root1.child.child.id).to.equal child2.id
-    expect(d._all_models).to.have.property(child1.id)
-    expect(d._all_models).to.have.property(child2.id)
-    expect(d._all_models).to.have.property(child3.id)
-
-    # put it back how it was before
-    event2 = new ModelChangedEvent(d, root1, 'child', child1.child, child1)
-    patch2 = d.create_json_patch_string([event2])
-    d.apply_json_patch(JSON.parse(patch2))
-
-    expect(root1.child.id).to.equal child1.id
-    expect(root1.child.child).to.be.equal null
-    expect(d._all_models).to.have.property(child1.id)
-    expect(d._all_models).to.not.have.property(child2.id)
-    expect(d._all_models).to.not.have.property(child3.id)
-
-  it "can patch two properties at once", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new SomeModel({ foo: 42 })
-    child1 = new SomeModel({ foo: 43 })
-    root1.setv({ child: child1 })
-    d.add_root(root1)
-    expect(d.roots().length).to.equal 1
-
-    child2 = new SomeModel({ foo: 44 })
-
-    event1 = new ModelChangedEvent(d, root1, 'foo', root1.foo, 57)
-    event2 = new ModelChangedEvent(d, root1, 'child', root1.child, child2)
-    patch1 = d.create_json_patch_string([event1, event2])
-    d.apply_json_patch(JSON.parse(patch1))
-
-
-    expect(root1.foo).to.equal 57
-    expect(root1.child.foo).to.be.equal 44
-
-  it "sets proper document on models added during patching", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new SomeModel({ foo: 42 })
-    child1 = new SomeModel({ foo: 44 })
-    d.add_root(root1)
-    expect(d.roots().length).to.equal 1
-
-    # can't create copy of doc here like other test. Testing explicitly that
-    # doc attach happens when *not* creating a new document (i.e only patching)
-    # Testing only for/against null .document is not the strongest test but it
-    # should suffice.
-
-    expect(root1.document.roots().length).equal 1
-    expect(root1.child).to.equal null
-
-    event1 = new ModelChangedEvent(d, root1, 'child', root1.child, child1)
-    patch1 = d.create_json_patch_string([event1])
-    d.apply_json_patch(JSON.parse(patch1))
-
-    expect(root1.document.roots().length).equal 1
-    expect(root1.child.document.roots().length).equal 1
-
-  it "sets proper document on models added during construction", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new ModelWithConstructTimeChanges()
-    # change it so it doesn't match what initialize() does
-    root1.foo = 3
-    root1.child = null
-    d.add_root(root1)
-
-    json = d.to_json_string()
-    parsed = JSON.parse(json)
-    parsed['version'] = js_version
-    copy = Document.from_json_string(JSON.stringify(parsed))
-
-    root1_copy = copy.get_model_by_id(root1.id)
-
-    expect(root1.foo).to.equal 3
-    expect(root1.child).to.equal null
-
-    # when unpacking the copy, initialize() was supposed to overwrite
-    # what we unpacked.
-    expect(root1_copy.foo).to.equal 4
-    expect(root1_copy.child).to.be.an.instanceof(AnotherModel)
-    expect(root1_copy.document).to.equal copy
-    expect(root1_copy.child.document).to.equal copy
-
-  it "computes patch for models added during construction", ->
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new ModelWithConstructTimeChanges()
-    # change it so it doesn't match what initialize() does
-    root1.foo = 3
-    root1.child = null
-    d.add_root(root1)
-
-    json = d.to_json_string()
-    parsed = JSON.parse(json)
-    parsed['version'] = js_version
-    copy = Document.from_json_string(JSON.stringify(parsed))
-
-    patch = Document._compute_patch_since_json(JSON.parse(json), copy)
-
-    expect(patch.events.length).to.equal 2
-    expect(root1.foo).to.equal 3
-    expect(root1.child).to.equal null
-    d.apply_json_patch(patch)
-    expect(root1.foo).to.equal 4
-    expect(root1.child).to.be.an.instanceof(AnotherModel)
-
-  it "computes complicated patch for models added during construction", ->
-    # this test simulates how from_json has to compute changes
-    # to send back to the server, when the client side makes
-    # changes while constructing the parsed document.
-    d = new Document()
-    expect(d.roots().length).to.equal 0
-    expect(Object.keys(d._all_models).length).to.equal 0
-
-    root1 = new ComplicatedModelWithConstructTimeChanges()
-    # change it so it doesn't match what initialize() does
-    serialized_values = {
-      'name' : 'foo',
-      'tags' : ['bar'],
-      'list_prop' : [new AnotherModel({ 'bar' : 42 })],
-      'dict_prop' : { foo: new AnotherModel({ 'bar' : 43 }) },
-      'obj_prop' : new ModelWithConstructTimeChanges(),
-      'dict_of_list_prop' : { foo: [new AnotherModel({ 'bar' : 44 })] }
-      }
-    root1.setv(serialized_values)
-
-    d.add_root(root1)
-
-    # in computing this, we will construct a
-    # ComplicatedModelWithConstructTimeChanges which will set
-    # stuff in initialize(), overwriting serialized_values above.
-    json = d.to_json_string()
-    parsed = JSON.parse(json)
-    parsed['version'] = js_version
-    copy = Document.from_json_string(JSON.stringify(parsed))
-
-    patch = Document._compute_patch_since_json(JSON.parse(json), copy)
-
-    # document should have the values we set above
-    for own key, value of serialized_values
-      expect(root1.getv(key)).to.deep.equal value
-    expect(root1.list_prop[0].bar).to.equal 42
-    expect(root1.dict_prop['foo'].bar).to.equal 43
-
-    expect(patch.events.length).to.equal 4
-
-    # but when we apply the patch, initialize() should override
-    # what we had in the json only for the four things that
-    # ComplicatedModelWithConstructTimeChanges changes (not name
-    # and tags)
-    d.apply_json_patch(patch)
-    expect(root1.name).to.equal 'foo'
-    expect(root1.tags).to.deep.equal ['bar']
-    expect(root1.list_prop.length).to.equal 1
-    expect(root1.list_prop[0].bar).to.equal 1
-    expect(Object.keys(root1.dict_prop).length).to.equal 1
-    expect(root1.dict_prop['foo'].bar).to.equal 1
-    expect(root1.obj_prop).to.be.an.instanceof(ModelWithConstructTimeChanges)
-    expect(root1.obj_prop.child).to.be.an.instanceof(AnotherModel)
-    expect(Object.keys(root1.dict_of_list_prop).length).to.equal 1
-    expect(values(root1.dict_of_list_prop)[0].length).to.equal 1
diff --git a/bokehjs/test/document/document.ts b/bokehjs/test/document/document.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/document/document.ts
@@ -0,0 +1,956 @@
+import {expect} from "chai"
+import * as sinon from "sinon"
+const {stderrTrap} = require('logtrap')
+
+import {values, size} from "core/util/object"
+import {Document, DEFAULT_TITLE} from "document"
+import * as ev from "document/events"
+import {version as js_version} from "version"
+import {Models} from "base"
+import {Model} from "model"
+import * as logging from "core/logging"
+import * as p from "core/properties"
+
+namespace AnotherModel {
+  export type Attrs = p.AttrsOf<Props>
+  export type Props = Model.Props & {
+    bar: p.Property<number>
+  }
+}
+
+interface AnotherModel extends AnotherModel.Attrs {}
+
+class AnotherModel extends Model {
+  properties: AnotherModel.Props
+
+  constructor(attrs?: Partial<AnotherModel.Attrs>) {
+    super(attrs)
+  }
+
+  static initClass(): void {
+    this.prototype.type = 'AnotherModel'
+
+    this.define<AnotherModel.Props>({
+      bar: [ p.Number, 1 ],
+    })
+  }
+}
+AnotherModel.initClass()
+
+Models.register('AnotherModel', AnotherModel)
+
+namespace SomeModel {
+  export type Attrs = p.AttrsOf<Props>
+  export type Props = Model.Props & {
+    foo: p.Property<number>
+    child: p.Property<Model | null>
+  }
+}
+
+interface SomeModel extends SomeModel.Attrs {}
+
+class SomeModel extends Model {
+  properties: SomeModel.Props
+
+  constructor(attrs?: Partial<SomeModel.Attrs>) {
+    super(attrs)
+  }
+
+  static initClass(): void {
+    this.prototype.type = 'SomeModel'
+
+    this.define<SomeModel.Props>({
+      foo:   [ p.Number, 2 ],
+      child: [ p.Instance, null ],
+    })
+  }
+}
+SomeModel.initClass()
+
+Models.register('SomeModel', SomeModel)
+
+namespace SomeModelWithChildren {
+  export type Attrs = p.AttrsOf<Props>
+  export type Props = Model.Props & {
+    children: p.Property<Model[]>
+  }
+}
+
+interface SomeModelWithChildren extends SomeModelWithChildren.Attrs {}
+
+class SomeModelWithChildren extends Model {
+  properties: SomeModelWithChildren.Props
+
+  constructor(attrs?: Partial<SomeModelWithChildren.Attrs>) {
+    super(attrs)
+  }
+
+  static initClass(): void {
+    this.prototype.type = 'SomeModelWithChildren'
+
+    this.define<SomeModelWithChildren.Props>({
+      children: [ p.Array, [] ],
+    })
+  }
+}
+SomeModelWithChildren.initClass()
+
+Models.register('SomeModelWithChildren', SomeModelWithChildren)
+
+namespace ModelWithConstructTimeChanges {
+  export type Attrs = p.AttrsOf<Props>
+  export type Props = Model.Props & {
+    foo: p.Property<number>
+    child: p.Property<Model | null>
+  }
+}
+
+interface ModelWithConstructTimeChanges extends ModelWithConstructTimeChanges.Attrs {}
+
+class ModelWithConstructTimeChanges extends Model {
+  properties: ModelWithConstructTimeChanges.Props
+
+  constructor(attrs?: Partial<ModelWithConstructTimeChanges.Attrs>) {
+    super(attrs)
+  }
+
+  initialize(): void {
+    super.initialize()
+    this.foo = 4
+    this.child = new AnotherModel()
+  }
+
+  static initClass(): void {
+    this.prototype.type = 'ModelWithConstructTimeChanges'
+
+    this.define<ModelWithConstructTimeChanges.Props>({
+      foo:   [ p.Number, 2 ],
+      child: [ p.Instance, null ],
+    })
+  }
+}
+ModelWithConstructTimeChanges.initClass()
+
+Models.register('ModelWithConstructTimeChanges', ModelWithConstructTimeChanges)
+
+namespace ComplicatedModelWithConstructTimeChanges {
+  export type Attrs = p.AttrsOf<Props>
+  export type Props = Model.Props & {
+    list_prop: p.Property<AnotherModel[]>
+    dict_prop: p.Property<{[key: string]: AnotherModel}>
+    obj_prop: p.Property<ModelWithConstructTimeChanges>
+    dict_of_list_prop: p.Property<{[key: string]: Model[]}>
+  }
+}
+
+interface ComplicatedModelWithConstructTimeChanges extends ComplicatedModelWithConstructTimeChanges.Attrs {}
+
+class ComplicatedModelWithConstructTimeChanges extends Model {
+  properties: ComplicatedModelWithConstructTimeChanges.Props
+
+  constructor(attrs?: Partial<ComplicatedModelWithConstructTimeChanges.Attrs>) {
+    super(attrs)
+  }
+
+  initialize(): void {
+    super.initialize()
+    this.list_prop = [new AnotherModel()]
+    this.dict_prop = { foo: new AnotherModel() }
+    this.obj_prop = new ModelWithConstructTimeChanges()
+    this.dict_of_list_prop = { foo: [new AnotherModel()] }
+  }
+
+  static initClass(): void {
+    this.prototype.type = 'ComplicatedModelWithConstructTimeChanges'
+
+    this.define<ComplicatedModelWithConstructTimeChanges.Props>({
+      list_prop:         [ p.Array ],
+      dict_prop:         [ p.Any ],
+      obj_prop:          [ p.Instance ],
+      dict_of_list_prop: [ p.Any ],
+    })
+  }
+}
+ComplicatedModelWithConstructTimeChanges.initClass()
+
+Models.register('ComplicatedModelWithConstructTimeChanges', ComplicatedModelWithConstructTimeChanges)
+
+describe("Document", () => {
+
+  let date_stub: sinon.SinonStub
+
+  beforeEach(() => {
+    date_stub = sinon.stub(Date, 'now')
+    date_stub.onCall(0).returns( 5)
+    date_stub.onCall(1).returns(10)
+    date_stub.onCall(2).returns(12)
+    date_stub.onCall(3).returns(15)
+    date_stub.onCall(4).returns(18)
+  })
+
+  afterEach(() => {
+    date_stub.restore()
+  })
+
+  it("should be constructable", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+  })
+
+  it("has working add_root", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    d.add_root(new AnotherModel())
+    expect(d.roots().length).to.equal(1)
+  })
+
+  it("manages noting interactivity periods", () => {
+    const d = new Document()
+    expect((d as any)._interactive_plot).to.be.null
+    expect((d as any)._interactive_timestamp).to.be.null
+    expect(d.interactive_duration()).to.equal(-1)
+
+    const m1 = new SomeModel()
+    const m2 = new AnotherModel()
+
+    d.interactive_start(m1)  // first stub value 10
+    expect((d as any)._interactive_plot.id).to.equal(m1.id)
+    expect((d as any)._interactive_timestamp).to.be.equal(10)
+    expect(d.interactive_duration()).to.be.equal(2) // second stub value 12
+
+    d.interactive_start(m2)  // third stub value 15
+    expect((d as any)._interactive_plot.id).to.equal(m1.id)
+    expect((d as any)._interactive_timestamp).to.be.equal(15)
+    expect(d.interactive_duration()).to.be.equal(3) // second stub value 18
+
+    d.interactive_stop(m1)
+    expect((d as any)._interactive_plot).to.be.null
+    expect((d as any)._interactive_timestamp).to.be.null
+    expect(d.interactive_duration()).to.equal(-1)
+  })
+
+  it("has working set_title", () => {
+    const d = new Document()
+    expect(d.title()).to.equal("Bokeh Application")
+    d.set_title("Foo")
+    expect(d.title()).to.equal("Foo")
+  })
+
+  it("tracks all_models", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+    const m = new SomeModel()
+    const m2 = new AnotherModel()
+    m.child = m2
+    expect(m.child).to.equal(m2)
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(Object.keys(d._all_models).length).to.equal(2)
+
+    m.child = null
+    expect(Object.keys(d._all_models).length).to.equal(1)
+    m.child = m2
+    expect(Object.keys(d._all_models).length).to.equal(2)
+    d.remove_root(m)
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+  })
+
+  it("tracks all_models with list property", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+    const m = new SomeModelWithChildren()
+    const m2 = new AnotherModel()
+    m.children = [m2]
+    expect(m.children).to.deep.equal([ m2 ])
+    // check that we get the right all_models on initial add_root
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(Object.keys(d._all_models).length).to.equal(2)
+
+    // check that removing children list drops the models beneath it
+    m.children = []
+    expect(Object.keys(d._all_models).length).to.equal(1)
+
+    // check that adding children back re-adds the models
+    m.children = [m2]
+    expect(Object.keys(d._all_models).length).to.equal(2)
+
+    // check that removing root removes the models
+    d.remove_root(m)
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+  })
+
+  it("tracks all_models with list property where list elements have a child", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+    const m = new SomeModelWithChildren()
+    const m3 = new AnotherModel()
+    const m2 = new SomeModel({ child: m3 })
+    m.children = [m2]
+    expect(m.children).to.deep.equal([ m2 ])
+
+    // check that we get the right all_models on initial add_root
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(Object.keys(d._all_models).length).to.equal(3)
+
+    // check that removing children list drops the models beneath it
+    m.children = []
+    expect(Object.keys(d._all_models).length).to.equal(1)
+
+    // check that adding children back re-adds the models
+    m.children = [m2]
+    expect(Object.keys(d._all_models).length).to.equal(3)
+
+    // check that removing root removes the models
+    d.remove_root(m)
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+  })
+
+  it("lets us get_model_by_id", () => {
+    const d = new Document()
+    const m = new SomeModel()
+    const m2 = new AnotherModel()
+    m.child = m2
+    d.add_root(m)
+    expect(d.get_model_by_id(m.id)).to.equal(m)
+    expect(d.get_model_by_id(m2.id)).to.equal(m2)
+    expect(d.get_model_by_id("invalidid")).to.equal(null)
+  })
+
+  it("lets us get_model_by_name", () => {
+    const d = new Document()
+    const m = new SomeModel({ name: "foo" })
+    const m2 = new AnotherModel({ name: "bar" })
+    m.child = m2
+    d.add_root(m)
+    expect(d.get_model_by_name(m.name!)).to.equal(m)
+    expect(d.get_model_by_name(m2.name!)).to.equal(m2)
+    expect(d.get_model_by_name("invalidid")).to.equal(null)
+  })
+
+  it("lets us get_model_by_name after changing name", () => {
+    const d = new Document()
+    const m = new SomeModel({ name: "foo" })
+    d.add_root(m)
+    expect(d.get_model_by_name("foo")).to.equal(m)
+    expect(d.get_model_by_name("bar")).to.equal(null)
+    m.name = "bar"
+    expect(d.get_model_by_name("foo")).to.equal(null)
+    expect(d.get_model_by_name("bar")).to.equal(m)
+  })
+
+  it("throws on get_model_by_name with duplicate name", () => {
+    const d = new Document()
+    const m = new SomeModel({ name: "foo" })
+    const m2 = new AnotherModel({ name: "foo" })
+    d.add_root(m)
+    d.add_root(m2)
+    let got_error = false
+    try {
+      d.get_model_by_name('foo')
+    } catch (e) {
+      got_error = true
+      expect(e.message).to.include('Multiple models')
+    }
+    expect(got_error).to.equal(true)
+  })
+
+  it("can have all_models with multiple references", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(size(d._all_models)).to.equal(0)
+
+    const root1 = new SomeModel()
+    const root2 = new SomeModel()
+    const child1 = new AnotherModel()
+    root1.child = child1
+    root2.child = child1
+    d.add_root(root1)
+    d.add_root(root2)
+    expect(d.roots().length).to.equal(2)
+    expect(size(d._all_models)).to.equal(3)
+
+    root1.child = null
+    expect(size(d._all_models)).to.equal(3)
+
+    root2.child = null
+    expect(size(d._all_models)).to.equal(2)
+
+    root1.child = child1
+    expect(size(d._all_models)).to.equal(3)
+
+    root2.child = child1
+    expect(size(d._all_models)).to.equal(3)
+
+    d.remove_root(root1)
+    expect(size(d._all_models)).to.equal(2)
+
+    d.remove_root(root2)
+    expect(size(d._all_models)).to.equal(0)
+  })
+
+  it("can have all_models with cycles", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(size(d._all_models)).to.equal(0)
+
+    const root1 = new SomeModel()
+    const root2 = new SomeModel()
+    const child1 = new SomeModel()
+    root1.child = child1
+    root2.child = child1
+    child1.child = root1
+    d.add_root(root1)
+    d.add_root(root2)
+    expect(d.roots().length).to.equal(2)
+    expect(size(d._all_models)).to.equal(3)
+
+    root1.child = null
+    expect(size(d._all_models)).to.equal(3)
+
+    root2.child = null
+    expect(size(d._all_models)).to.equal(2)
+
+    root1.child = child1
+    expect(size(d._all_models)).to.equal(3)
+  })
+
+  it("can have all_models with cycles through lists", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(size(d._all_models)).to.equal(0)
+
+    const root1 = new SomeModelWithChildren()
+    const root2 = new SomeModelWithChildren()
+    const child1 = new SomeModelWithChildren()
+    root1.children = [child1]
+    root2.children = [child1]
+    child1.children = [root1]
+    d.add_root(root1)
+    d.add_root(root2)
+    expect(d.roots().length).to.equal(2)
+    expect(size(d._all_models)).to.equal(3)
+
+    root1.children = []
+    expect(size(d._all_models)).to.equal(3)
+
+    root2.children = []
+    expect(size(d._all_models)).to.equal(2)
+
+    root1.children = [child1]
+    expect(size(d._all_models)).to.equal(3)
+  })
+
+  it("can notify on changes", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+
+    const m = new AnotherModel()
+
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(m.bar).to.equal(1)
+
+    const events: ev.DocumentChangedEvent[] = []
+    d.on_change((event) => events.push(event))
+
+    m.bar = 42
+    expect(events.length).to.equal(1)
+    expect(events[0]).is.instanceof(ev.ModelChangedEvent)
+    const event = events[0] as ev.ModelChangedEvent
+    expect(event.document).to.equal(d)
+    expect(event.model).to.equal(m)
+    expect(event.attr).to.equal('bar')
+    expect(event.old).to.equal(1)
+    expect(event.new_).to.equal(42)
+  })
+
+  it("can remove notification changes", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+
+    const m = new AnotherModel()
+
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(m.bar).to.equal(1)
+
+    const events: ev.DocumentChangedEvent[] = []
+    const listener = (event: ev.DocumentChangedEvent) => events.push(event)
+    d.on_change(listener)
+
+    m.bar = 42
+
+    expect(events.length).to.equal(1)
+    expect(events[0]).is.instanceof(ev.ModelChangedEvent)
+    const event0 = events[0] as ev.ModelChangedEvent
+    expect(event0.new_).to.equal(42)
+
+    d.remove_on_change(listener)
+    m.bar = 43
+
+    expect(events.length).to.equal(1)
+  })
+
+  it("should notify on roots change", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+
+    const events: ev.DocumentChangedEvent[] = []
+    d.on_change((event) => events.push(event))
+
+    const m = new AnotherModel({bar:1})
+    d.add_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(events.length).to.equal(1)
+    expect(events[0]).is.instanceof(ev.RootAddedEvent)
+    const event0 = events[0] as ev.RootAddedEvent
+    expect(event0.model).to.equal(m)
+
+    const m2 = new AnotherModel({bar:2})
+    d.add_root(m2)
+    expect(d.roots().length).to.equal(2)
+    expect(events.length).to.equal(2)
+    expect(events[1]).is.instanceof(ev.RootAddedEvent)
+    const event1 = events[1] as ev.RootAddedEvent
+    expect(event1.model).to.equal(m2)
+
+    d.remove_root(m)
+    expect(d.roots().length).to.equal(1)
+    expect(events.length).to.equal(3)
+    expect(events[2]).is.instanceof(ev.RootRemovedEvent)
+    const event2 = events[2] as ev.RootRemovedEvent
+    expect(event2.model).to.equal(m)
+
+    d.remove_root(m2)
+    expect(d.roots().length).to.equal(0)
+    expect(events.length).to.equal(4)
+    expect(events[3]).is.instanceof(ev.RootRemovedEvent)
+    const event3 = events[3] as ev.RootRemovedEvent
+    expect(event3.model).to.equal(m2)
+  })
+
+  it("should notify on title change", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(d.title()).to.equal(DEFAULT_TITLE)
+
+    const events: ev.DocumentChangedEvent[] = []
+    d.on_change((event) => events.push(event))
+
+    d.set_title('Foo')
+    expect(d.title()).to.equal('Foo')
+    expect(events.length).to.equal(1)
+    expect(events[0]).is.instanceof(ev.TitleChangedEvent)
+    const event = events[0] as ev.TitleChangedEvent
+    expect(event.document).to.equal(d)
+    expect(event.title).to.equal('Foo')
+  })
+
+  it("can clear", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(d.title()).to.equal(DEFAULT_TITLE)
+    d.add_root(new AnotherModel())
+    d.add_root(new AnotherModel())
+    d.set_title('Foo')
+    expect(d.roots().length).to.equal(2)
+    expect(d.title()).to.equal('Foo')
+    d.clear()
+    expect(d.roots().length).to.equal(0)
+    expect(size(d._all_models)).to.equal(0)
+    // does not reset title
+    expect(d.title()).to.equal('Foo')
+  })
+
+  it("throws on destructive move of itself", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(d.title()).to.equal(DEFAULT_TITLE)
+    d.add_root(new AnotherModel())
+    d.add_root(new AnotherModel())
+    d.set_title('Foo')
+    expect(d.roots().length).to.equal(2)
+    expect(d.title()).to.equal('Foo')
+    let got_error = false
+    try {
+      d.destructively_move(d)
+    } catch (e) {
+      got_error = true
+      expect(e.message).to.include('Attempted to overwrite a document with itself')
+    }
+    expect(got_error).to.equal(true)
+  })
+
+  it("can destructively move", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(d.title()).to.equal(DEFAULT_TITLE)
+    d.add_root(new AnotherModel())
+    d.add_root(new AnotherModel())
+    d.set_title('Foo')
+    expect(d.roots().length).to.equal(2)
+    expect(d.title()).to.equal('Foo')
+
+    const d2 = new Document()
+    expect(d2.roots().length).to.equal(0)
+    expect(d2.title()).to.equal(DEFAULT_TITLE)
+    d2.add_root(new SomeModel())
+    d2.set_title('Bar')
+    expect(d2.roots().length).to.equal(1)
+    expect(d2.title()).to.equal('Bar')
+
+    d2.destructively_move(d)
+    expect(d.roots().length).to.equal(1)
+    expect(d.roots()[0]).to.be.an.instanceof(SomeModel)
+    const root0 = d.roots()[0] as SomeModel
+    expect(root0.foo).to.equal(2)
+    expect(d.title()).to.equal('Bar')
+
+    expect(d2.roots().length).to.equal(0)
+  })
+
+  it("checks for versions matching", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    const root1 = new SomeModel()
+    d.add_root(root1)
+    expect(d.roots().length).to.equal(1)
+    d.set_title("Foo")
+
+    const old_log_level = logging.logger.level.name
+    logging.set_log_level("warn")
+    const json = d.to_json_string()
+    const parsed = JSON.parse(json)
+    parsed.version = `${js_version}`
+    const out0 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out0).to.be.equal("")
+
+    parsed.version = "0.0.1"
+    const out1 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out1).to.be.equal(`[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (${js_version}) / Python (${parsed.version})\n`)
+
+    parsed.version = `${js_version}rc123`
+    const out2 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out2).to.be.equal(`[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (${js_version}) / Python (${parsed.version})\n`)
+
+    parsed.version = `${js_version}dev123`
+    const out3 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out3).to.be.equal(`[bokeh] JS/Python version mismatch\n[bokeh] Library versions: JS (${js_version}) / Python (${parsed.version})\n`)
+
+    parsed.version = `${js_version}-foo`
+    const out4 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out4).to.be.equal("")
+
+    parsed.version = `${js_version}rc123-foo`
+    const out5 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out5).to.be.equal("")
+
+    parsed.version = `${js_version}dev123-bar`
+    const out6 = stderrTrap(() => Document.from_json_string(JSON.stringify(parsed)))
+    expect(out6).to.be.equal("")
+
+    // need to reset old log level
+    logging.set_log_level(old_log_level)
+  })
+
+  it("can serialize with one model in it", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    const root1 = new SomeModel()
+    d.add_root(root1)
+    expect(d.roots().length).to.equal(1)
+    d.set_title("Foo")
+
+    const json = d.to_json_string()
+    const parsed = JSON.parse(json)
+    parsed.version = js_version
+    const copy = Document.from_json_string(JSON.stringify(parsed))
+
+    expect(copy.roots().length).to.equal(1)
+    expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
+    expect(copy.title()).to.equal("Foo")
+  })
+
+  it("can serialize excluding defaults", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    const root1 = new SomeModel()
+    root1.name = 'bar'
+    d.add_root(root1)
+    expect(d.roots().length).to.equal(1)
+
+    const json = d.to_json_string(false)
+    const parsed = JSON.parse(json)
+    parsed.version = js_version
+    const copy = Document.from_json_string(JSON.stringify(parsed))
+
+    expect(copy.roots().length).to.equal(1)
+    expect(copy.roots()[0]).to.be.an.instanceof(SomeModel)
+    expect(copy.roots()[0].name).to.be.equal('bar')
+
+    // be sure defaults were NOT included
+    const attrs0 = parsed.roots.references[0].attributes
+    expect('tags' in attrs0).to.be.equal(false)
+    expect('foo' in attrs0).to.be.equal(false)
+    expect('child' in attrs0).to.be.equal(false)
+    // this should be included, non-default
+    expect('name' in attrs0).to.be.equal(true)
+
+    // double-check different results if we do include_defaults
+    const parsed_with_defaults = JSON.parse(d.to_json_string(true))
+    const attrs1 = parsed_with_defaults.roots.references[0].attributes
+    //expect('tags' in attrs1).to.be.equal(true)
+    expect('foo' in attrs1).to.be.equal(true)
+    expect('child' in attrs1).to.be.equal(true)
+    expect('name' in attrs1).to.be.equal(true)
+  })
+
+  // TODO copy the following tests from test_document.py here
+  // TODO(havocp) test_serialization_more_models
+
+  it("can patch an integer property", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new SomeModel({ foo: 42 })
+    const root2 = new SomeModel({ foo: 43 })
+    const child1 = new SomeModel({ foo: 44 })
+    root1.child = child1
+    root2.child = child1
+    d.add_root(root1)
+    d.add_root(root2)
+    expect(d.roots().length).to.equal(2)
+
+    const event1 = new ev.ModelChangedEvent(d, root1, 'foo', root1.foo, 57)
+    const patch1 = d.create_json_patch_string([event1])
+    d.apply_json_patch(JSON.parse(patch1))
+
+    expect(root1.foo).to.equal(57)
+
+    const event2 = new ev.ModelChangedEvent(d, child1, 'foo', child1.foo, 67)
+    const patch2 = d.create_json_patch_string([event2])
+    d.apply_json_patch(JSON.parse(patch2))
+
+    expect(child1.foo).to.equal(67)
+  })
+
+  it("can patch a reference property", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new SomeModel({ foo: 42 })
+    const root2 = new SomeModel({ foo: 43 })
+    const child1 = new SomeModel({ foo: 44 })
+    const child2 = new SomeModel({ foo: 45 })
+    const child3 = new SomeModel({ foo: 46, child: child2})
+    root1.child = child1
+    root2.child = child1
+    d.add_root(root1)
+    d.add_root(root2)
+    expect(d.roots().length).to.equal(2)
+
+    expect(d._all_models).to.have.property(child1.id)
+    expect(d._all_models).to.not.have.property(child2.id)
+    expect(d._all_models).to.not.have.property(child3.id)
+
+    const event1 = new ev.ModelChangedEvent(d, root1, 'child', root1.child, child3)
+    const patch1 = d.create_json_patch_string([event1])
+    d.apply_json_patch(JSON.parse(patch1))
+
+    expect(root1.child.id).to.equal(child3.id)
+    expect(root1.child).to.be.instanceof(SomeModel)
+    const root1_child0 = root1.child as SomeModel
+    expect(root1_child0.child!.id).to.equal(child2.id)
+    expect(d._all_models).to.have.property(child1.id)
+    expect(d._all_models).to.have.property(child2.id)
+    expect(d._all_models).to.have.property(child3.id)
+
+    // put it back how it was before
+    const event2 = new ev.ModelChangedEvent(d, root1, 'child', child1.child, child1)
+    const patch2 = d.create_json_patch_string([event2])
+    d.apply_json_patch(JSON.parse(patch2))
+
+    expect(root1.child.id).to.equal(child1.id)
+    expect(root1.child).to.be.instanceof(SomeModel)
+    const root1_child1 = root1.child as SomeModel
+    expect(root1_child1.child).to.be.equal(null)
+    expect(d._all_models).to.have.property(child1.id)
+    expect(d._all_models).to.not.have.property(child2.id)
+    expect(d._all_models).to.not.have.property(child3.id)
+  })
+
+  it("can patch two properties at once", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new SomeModel({ foo: 42 })
+    const child1 = new SomeModel({ foo: 43 })
+    root1.child = child1
+    d.add_root(root1)
+    expect(d.roots().length).to.equal(1)
+
+    const child2 = new SomeModel({ foo: 44 })
+
+    const event1 = new ev.ModelChangedEvent(d, root1, 'foo', root1.foo, 57)
+    const event2 = new ev.ModelChangedEvent(d, root1, 'child', root1.child, child2)
+    const patch1 = d.create_json_patch_string([event1, event2])
+    d.apply_json_patch(JSON.parse(patch1))
+
+    expect(root1.foo).to.equal(57)
+    expect(root1.child).to.be.instanceof(SomeModel)
+    const root1_child0 = root1.child as SomeModel
+    expect(root1_child0.foo).to.be.equal(44)
+  })
+
+  it("sets proper document on models added during patching", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new SomeModel({ foo: 42 })
+    const child1 = new SomeModel({ foo: 44 })
+    d.add_root(root1)
+    expect(d.roots().length).to.equal(1)
+
+    // can't create copy of doc here like other test. Testing explicitly that
+    // doc attach happens when *not* creating a new document (i.e only patching)
+    // Testing only for/against null .document is not the strongest test but it
+    // should suffice.
+
+    expect(root1.document!.roots().length).equal(1)
+    expect(root1.child).to.equal(null)
+
+    const event1 = new ev.ModelChangedEvent(d, root1, 'child', root1.child, child1)
+    const patch1 = d.create_json_patch_string([event1])
+    d.apply_json_patch(JSON.parse(patch1))
+
+    expect(root1.document!.roots().length).equal(1)
+    expect(root1.child!.document!.roots().length).equal(1)
+  })
+
+  it("sets proper document on models added during construction", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new ModelWithConstructTimeChanges()
+    // change it so it doesn't match what initialize() does
+    root1.foo = 3
+    root1.child = null
+    d.add_root(root1)
+
+    const json = d.to_json_string()
+    const parsed = JSON.parse(json)
+    parsed.version = js_version
+    const copy = Document.from_json_string(JSON.stringify(parsed))
+
+    const root1_copy = copy.get_model_by_id(root1.id) as ModelWithConstructTimeChanges
+
+    expect(root1.foo).to.equal(3)
+    expect(root1.child).to.equal(null)
+
+    // when unpacking the copy, initialize() was supposed to overwrite
+    // what we unpacked.
+    expect(root1_copy.foo).to.equal(4)
+    expect(root1_copy.child).to.be.an.instanceof(AnotherModel)
+    expect(root1_copy.document).to.equal(copy)
+    expect(root1_copy.child!.document).to.equal(copy)
+  })
+
+  it("computes patch for models added during construction", () => {
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new ModelWithConstructTimeChanges()
+    // change it so it doesn't match what initialize() does
+    root1.foo = 3
+    root1.child = null
+    d.add_root(root1)
+
+    const json = d.to_json_string()
+    const parsed = JSON.parse(json)
+    parsed.version = js_version
+    const copy = Document.from_json_string(JSON.stringify(parsed))
+
+    const patch = Document._compute_patch_since_json(JSON.parse(json), copy)
+
+    expect(patch.events.length).to.equal(2)
+    expect(root1.foo).to.equal(3)
+    expect(root1.child).to.equal(null)
+    d.apply_json_patch(patch)
+    expect(root1.foo).to.equal(4)
+    expect(root1.child).to.be.an.instanceof(AnotherModel)
+  })
+
+  it("computes complicated patch for models added during construction", () => {
+    // this test simulates how from_json has to compute changes
+    // to send back to the server, when the client side makes
+    // changes while constructing the parsed document.
+    const d = new Document()
+    expect(d.roots().length).to.equal(0)
+    expect(Object.keys(d._all_models).length).to.equal(0)
+
+    const root1 = new ComplicatedModelWithConstructTimeChanges()
+    // change it so it doesn't match what initialize() does
+    const serialized_values = {
+      name: 'foo',
+      tags: ['bar'],
+      list_prop: [new AnotherModel({ bar: 42 })],
+      dict_prop: { foo: new AnotherModel({ bar: 43 }) },
+      obj_prop: new ModelWithConstructTimeChanges(),
+      dict_of_list_prop: { foo: [new AnotherModel({ bar: 44 })] },
+    }
+    root1.setv(serialized_values)
+
+    d.add_root(root1)
+
+    // in computing this, we will construct a
+    // ComplicatedModelWithConstructTimeChanges which will set
+    // stuff in initialize(), overwriting serialized_values above.
+    const json = d.to_json_string()
+    const parsed = JSON.parse(json)
+    parsed.version = js_version
+    const copy = Document.from_json_string(JSON.stringify(parsed))
+
+    const patch = Document._compute_patch_since_json(JSON.parse(json), copy)
+
+    // document should have the values we set above
+    for (const key in serialized_values) {
+      const value = (serialized_values as any)[key] // XXX: own
+      expect(root1.getv(key)).to.deep.equal(value)
+    }
+
+    expect(root1.list_prop[0].bar).to.equal(42)
+    expect(root1.dict_prop.foo.bar).to.equal(43)
+
+    expect(patch.events.length).to.equal(4)
+
+    // but when we apply the patch, initialize() should override
+    // what we had in the json only for the four things that
+    // ComplicatedModelWithConstructTimeChanges changes (not name
+    // and tags)
+    d.apply_json_patch(patch)
+    expect(root1.name).to.equal('foo')
+    expect(root1.tags).to.deep.equal(['bar'])
+    expect(root1.list_prop.length).to.equal(1)
+    expect(root1.list_prop[0].bar).to.equal(1)
+    expect(Object.keys(root1.dict_prop).length).to.equal(1)
+    expect(root1.dict_prop.foo.bar).to.equal(1)
+    expect(root1.obj_prop).to.be.an.instanceof(ModelWithConstructTimeChanges)
+    expect(root1.obj_prop.child).to.be.an.instanceof(AnotherModel)
+    expect(Object.keys(root1.dict_of_list_prop).length).to.equal(1)
+    expect(values(root1.dict_of_list_prop)[0].length).to.equal(1)
+  })
+})
diff --git a/bokehjs/test/index.coffee b/bokehjs/test/index.coffee
deleted file mode 100644
--- a/bokehjs/test/index.coffee
+++ /dev/null
@@ -1,52 +0,0 @@
-require("source-map-support").install()
-
-# XXX: Patch jsdom/utils before importing main jsdom. Otherwise
-#      HTMLCanvasElement will detect no canvas and make tests
-#      fail with "unable to obtain 2D rendering context".
-utils = require('jsdom/lib/jsdom/utils')
-if not utils.Canvas?
-  class Canvas
-    constructor: (@width, @height) ->
-
-    getContext: (contextType) ->
-      return {
-        beginPath:   () -> return null
-        clearRect:   () -> return null
-        clip:        () -> return null
-        drawImage:   () -> return null
-        fillRect:    () -> return null
-        fillText:    () -> return null
-        lineTo:      () -> return null
-        measureText: () -> return {width: 1, ascent: 1}
-        moveTo:      () -> return null
-        rect:        () -> return null
-        restore:     () -> return null
-        rotate:      () -> return null
-        save:        () -> return null
-        scale:       () -> return null
-        stroke:      () -> return null
-        strokeRect:  () -> return null
-        translate:   () -> return null
-      }
-
-  utils.Canvas = Canvas
-
-jsdom = require('jsdom').jsdom
-
-global.document = jsdom()
-global.window = document.defaultView
-
-blacklist = Object.keys(global)
-blacklist.push('constructor')
-blacklist.push('console')
-blacklist.push('Console')
-
-for own key, val of global.window
-  if blacklist.indexOf(key) == -1
-    global[key] = val
-
-global.HTMLElement = global.window.HTMLElement
-global.Event = global.window.Event
-global.Image = global.window.Image
-global.atob = require "atob"
-global.btoa = require "btoa"
diff --git a/bokehjs/test/index.ts b/bokehjs/test/index.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/test/index.ts
@@ -0,0 +1,62 @@
+require("source-map-support").install()
+
+// XXX: Patch jsdom/utils before importing main jsdom. Otherwise
+//      HTMLCanvasElement will detect no canvas and make tests
+//      fail with "unable to obtain 2D rendering context".
+const utils = require('jsdom/lib/jsdom/utils')
+if (utils.Canvas == null) {
+  class Canvas {
+    constructor(readonly width: number, readonly height: number) {}
+
+    getContext(_contextType: string): any {
+      return {
+        beginPath()   { return null },
+        clearRect()   { return null },
+        clip()        { return null },
+        drawImage()   { return null },
+        fillRect()    { return null },
+        fillText()    { return null },
+        lineTo()      { return null },
+        measureText() { return { width: 1, ascent: 1} },
+        moveTo()      { return null },
+        rect()        { return null },
+        restore()     { return null },
+        rotate()      { return null },
+        save()        { return null },
+        scale()       { return null },
+        stroke()      { return null },
+        strokeRect()  { return null },
+        translate()   { return null },
+      }
+    }
+  }
+
+  utils.Canvas = Canvas
+}
+
+const jsdom = require('jsdom').jsdom
+
+const _global: any = global
+
+_global.document = jsdom()
+_global.window = document.defaultView
+
+const blacklist = Object.keys(_global)
+blacklist.push('constructor')
+blacklist.push('console')
+blacklist.push('Console')
+
+for (const key in _global.window) {
+  if (_global.window.hasOwnProperty(key)) {
+    if (blacklist.indexOf(key) == -1) {
+      const val = _global.window[key]
+      _global[key] = val
+    }
+  }
+}
+
+_global.HTMLElement = _global.window.HTMLElement
+_global.Event = _global.window.Event
+_global.Image = _global.window.Image
+_global.atob = require("atob")
+_global.btoa = require("btoa")