Repository: bokeh/bokeh

Issue:
Task: pytest marked "selenium" tests should assert no browser console errors.
Task: pytest marked "selenium" tests should assert no browser console errors.


Discussion:
This is only currently possible in Chrome, AFAICT: https://github.com/mozilla/geckodriver/issues/284

Even just 
```
driver.get_log('browser')
```
fails with an exception with the FF driver. 

Edit: also fails with Safari driver

Resolving patch:
diff --git a/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -8,6 +8,10 @@
 # Unfortunately these seem to all need to be centrally defined at the top level
 def pytest_addoption(parser):
 
+    # plugins/selenium
+    parser.addoption(
+        "--driver", choices=('chrome', 'firefox', 'safari'), default='chrome', help='webdriver implementation')
+
     # plugins/bokeh_server
     parser.addoption(
         "--bokeh-port", dest="bokeh_port", type=int, default=5006, help="port on which Bokeh server resides"


Test patch:
diff --git a/bokeh/_testing/plugins/bokeh.py b/bokeh/_testing/plugins/bokeh.py
new file mode 100644
--- /dev/null
+++ b/bokeh/_testing/plugins/bokeh.py
@@ -0,0 +1,160 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+''' Define a Pytest plugin for a Bokeh-specific testing tools
+
+'''
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+log = logging.getLogger(__name__)
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+import pytest
+from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+
+# Bokeh imports
+from bokeh.io import save
+from bokeh._testing.util.selenium import INIT, RESULTS, wait_for_canvas_resize
+
+#-----------------------------------------------------------------------------
+# Globals and constants
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+    "bokeh._testing.plugins.file_server",
+    "bokeh._testing.plugins.selenium",
+)
+
+#-----------------------------------------------------------------------------
+# General API
+#-----------------------------------------------------------------------------
+
+@pytest.fixture
+def output_file_url(request, file_server):
+    from bokeh.io import output_file
+    filename = request.function.__name__ + '.html'
+    file_obj = request.fspath.dirpath().join(filename)
+    file_path = file_obj.strpath
+    url = file_path.replace('\\', '/')  # Windows-proof
+
+    output_file(file_path, mode='inline')
+
+    def tear_down():
+        if file_obj.isfile():
+            file_obj.remove()
+    request.addfinalizer(tear_down)
+
+    return file_server.where_is(url)
+
+class _BokehModelPage(object):
+
+    def __init__(self, model, driver, output_file_url, has_no_console_errors):
+        self._driver = driver
+        self._model = model
+        self._has_no_console_errors = has_no_console_errors
+
+        save(self._model)
+
+        self._driver.get(output_file_url)
+
+        self.init_results()
+
+    @property
+    def results(self):
+        WebDriverWait(self._driver, 10).until(EC.staleness_of(self.test_div))
+        self.test_div = self._driver.find_element_by_class_name("bokeh-test-div")
+        return self._driver.execute_script(RESULTS)
+
+    @property
+    def driver(self):
+        return self._driver
+
+    def init_results(self):
+        self._driver.execute_script(INIT)
+        self.test_div = self._driver.find_element_by_class_name("bokeh-test-div")
+
+    def click_element_at_position(self, element, x, y):
+        actions = ActionChains(self._driver)
+        actions.move_to_element_with_offset(element, x, y)
+        actions.click()
+        actions.perform()
+
+    def drag_element_at_position(self, element, x, y, dx, dy, mod=None):
+        actions = ActionChains(self._driver)
+        if mod:
+            actions.key_down(mod)
+        actions.move_to_element_with_offset(element, x, y)
+        actions.click_and_hold()
+        actions.move_by_offset(dx, dy)
+        actions.release()
+        if mod:
+            actions.key_up(mod)
+        actions.perform()
+
+    def has_no_console_errors(self):
+        return self._has_no_console_errors(self._driver)
+
+@pytest.fixture()
+def bokeh_model_page(driver, output_file_url, has_no_console_errors):
+    def func(model):
+        return _BokehModelPage(model, driver, output_file_url, has_no_console_errors)
+    return func
+
+class _SinglePlotPage(_BokehModelPage):
+
+    # model may be a layout, but should only contain a single plot
+    def __init__(self, model, driver, output_file_url, has_no_console_errors):
+        super(_SinglePlotPage, self).__init__(model, driver, output_file_url, has_no_console_errors)
+
+        self.canvas = self._driver.find_element_by_tag_name('canvas')
+        wait_for_canvas_resize(self.canvas, self._driver)
+
+    def click_canvas_at_position(self, x, y):
+        self.click_element_at_position(self.canvas, x, y)
+
+    def click_custom_action(self):
+        button = self._driver.find_element_by_class_name("bk-toolbar-button-custom-action")
+        button.click()
+
+    def drag_canvas_at_position(self, x, y, dx, dy):
+        self.drag_element_at_position(self.canvas, x, y, dx, dy)
+
+    def get_toolbar_button(self, name):
+        return self.driver.find_element_by_class_name('bk-tool-icon-' + name)
+
+
+@pytest.fixture()
+def single_plot_page(driver, output_file_url, has_no_console_errors):
+    def func(model):
+        return _SinglePlotPage(model, driver, output_file_url, has_no_console_errors)
+    return func
+
+#-----------------------------------------------------------------------------
+# Dev API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Code
+#-----------------------------------------------------------------------------
diff --git a/bokeh/_testing/plugins/selenium.py b/bokeh/_testing/plugins/selenium.py
new file mode 100644
--- /dev/null
+++ b/bokeh/_testing/plugins/selenium.py
@@ -0,0 +1,124 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+''' Define a Pytest plugin for a log file fixture
+
+'''
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import logging
+log = logging.getLogger(__name__)
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+from warnings import warn
+
+# External imports
+import pytest
+from selenium import webdriver
+
+# Bokeh imports
+
+#-----------------------------------------------------------------------------
+# Globals and constants
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# General API
+#-----------------------------------------------------------------------------
+
+def pytest_report_collectionfinish(config, startdir, items):
+    '''
+
+    '''
+    driver  = config.getoption('driver', 'chrome').lower()
+    asserts = "ON" if driver == "chrome" else "OFF"
+    return ["", "Bokeh selenium tests using %r driver (no-console-error assertions: %s)" % (driver, asserts)]
+
+@pytest.yield_fixture(scope="session")
+def driver(pytestconfig):
+    ''' Select and configure a Selenium webdriver for integration tests.
+
+    '''
+    driver_name  = pytestconfig.getoption('driver', 'chrome').lower()
+
+    if driver_name == "chrome":
+        from selenium.webdriver.chrome.options import Options
+        options = Options()
+        options.add_argument("--headless")
+        options.add_argument("--no-sandbox")
+        options.add_argument("--window-size=1920x1080")
+        driver = webdriver.Chrome(chrome_options=options)
+
+    elif driver_name == "firefox":
+        from selenium.webdriver.firefox.options import Options
+        options = Options()
+        options.add_argument("--headless")
+        options.add_argument("--window-size=1920x1080")
+        driver = webdriver.Firefox(firefox_options=options)
+
+    elif driver_name == "safari":
+        driver = webdriver.Safari()
+
+    driver.implicitly_wait(10)
+
+    yield driver
+
+    driver.quit()
+
+@pytest.fixture(scope="session")
+def has_no_console_errors(pytestconfig):
+    ''' Provide a function to assert no browser console errors are present.
+
+    Unfortunately logs are only accessibly with Chrome web driver, see e.g.
+
+        https://github.com/mozilla/geckodriver/issues/284
+
+    For non-Chrome webdrivers this check always returns True.
+
+    '''
+    driver_name  = pytestconfig.getoption('driver').lower()
+
+    if driver_name == "chrome":
+
+        def func(driver):
+            logs = driver.get_log('browser')
+            severe_errors = [x for x in logs if x.get('level') == 'SEVERE']
+            non_network_errors = [l for l in severe_errors if l.get('type') != 'network']
+
+            if len(non_network_errors) == 0:
+                if len(severe_errors) != 0:
+                    warn("There were severe network errors (this may or may not have affected your test): %s" % severe_errors)
+                return True
+
+            pytest.fail('Console errors: %s' % non_network_errors)
+
+
+    else:
+        def func(driver):
+            return True
+
+    return func
+
+#-----------------------------------------------------------------------------
+# Dev API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Private API
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Code
+#-----------------------------------------------------------------------------
diff --git a/bokeh/_testing/util/selenium.py b/bokeh/_testing/util/selenium.py
--- a/bokeh/_testing/util/selenium.py
+++ b/bokeh/_testing/util/selenium.py
@@ -22,14 +22,15 @@
 #-----------------------------------------------------------------------------
 
 # Standard library imports
-import pytest
 
 # External imports
 from selenium.common.exceptions import TimeoutException
+from selenium.webdriver.common.action_chains import ActionChains
 from selenium.webdriver.support.ui import WebDriverWait
 
 # Bokeh imports
-from bokeh.util.terminal import warn
+from bokeh.models import Button
+from bokeh.util.serialization import make_id
 
 #-----------------------------------------------------------------------------
 # Globals and constants
@@ -39,6 +40,33 @@
 # General API
 #-----------------------------------------------------------------------------
 
+def COUNT(key):
+    return 'Bokeh._testing.count(%r);' % key
+
+INIT = 'Bokeh._testing.init();'
+
+def RECORD(key, value):
+    return 'Bokeh._testing.record(%r, %s);' % (key, value)
+
+RESULTS = 'return Bokeh._testing.results'
+
+def SCROLL(amt):
+    return """
+    var elt = document.getElementsByClassName("bk-canvas-events")[0];
+    var event = new WheelEvent('wheel', { deltaY: %f, clientX: 100, clientY: 100} );
+    elt.dispatchEvent(event);
+    """ % amt
+
+class ButtonWrapper(object):
+    def __init__(self, label, callback):
+        self.id = "button-" + make_id()
+        self.obj = Button(label=label, css_classes=[self.id])
+        self.obj.js_on_event('button_click', callback)
+
+    def click(self, driver):
+        button = driver.find_element_by_class_name(self.id)
+        button.click()
+
 class element_to_start_resizing(object):
     ''' An expectation for checking if an element has started resizing
     '''
@@ -70,21 +98,16 @@ def __call__(self, driver):
             self.previous_width = current_width
             return False
 
-def has_no_console_errors(selenium):
-    ''' Helper function to detect console errors.
 
-    '''
-    logs = selenium.get_log('browser')
-    severe_errors = [l for l in logs if l.get('level') == 'SEVERE']
-    non_network_errors = [l for l in severe_errors if l.get('type') != 'network']
-    if len(non_network_errors) == 0:
-        return True
-    else:
-        pytest.fail('Console errors: %s' % non_network_errors)
-    if len(non_network_errors) == 0 and len(severe_errors) != 0:
-        warn("There were severe network errors (this may or may not have affected your test): %s" % severe_errors)
-    canvas = selenium.find_element_by_tag_name('canvas')
-    wait_for_canvas_resize(canvas, selenium)
+def enter_text_in_cell(driver, cell, text):
+    actions = ActionChains(driver)
+    actions.move_to_element(cell)
+    actions.double_click()
+    actions.send_keys(text + u"\ue007")  # After the backslash is ENTER key
+    actions.perform()
+
+def get_table_cell(driver, row, col):
+    return driver.find_element_by_css_selector('.grid-canvas .slick-row:nth-child(%d) .r%d' % (row, col))
 
 def wait_for_canvas_resize(canvas, test_driver):
     '''
diff --git a/bokehjs/src/lib/testing.ts b/bokehjs/src/lib/testing.ts
new file mode 100644
--- /dev/null
+++ b/bokehjs/src/lib/testing.ts
@@ -0,0 +1,42 @@
+// Just a dumb key/value record for collecting arbitrary info for tests
+export const results: Record<string, any> = {}
+
+// Selenium has race conditions that make it difficult to read out the
+// results structure. This function deletes/creates a div that can act as
+// a semaphore. Tests should wait for the previous div to be stale, then
+// find the new div. At that point the results should be available
+function _update_test_div(): void {
+  const body = document.getElementsByTagName("body")[0]
+  const col = document.getElementsByClassName("bokeh-test-div");
+
+  if (col.length == 1)
+    body.removeChild(col[0])
+    delete col[0]
+
+  const box = document.createElement("div")
+  box.classList.add("bokeh-test-div")
+  box.style.display = "none"
+  body.insertBefore(box, body.firstChild)
+}
+
+export function init(): void {
+  _update_test_div()
+}
+
+export function record(key: string, value: any): void {
+  results[key] = value
+  _update_test_div()
+}
+
+export function count(key: string): void {
+  if (results[key] == undefined)
+    results[key] = 0
+  results[key] += 1
+  _update_test_div()
+}
+
+export function clear(): void {
+  for (const prop of Object.keys(results))
+    delete results[prop]
+  _update_test_div()
+}
diff --git a/scripts/ci/test.integration b/scripts/ci/test.integration
--- a/scripts/ci/test.integration
+++ b/scripts/ci/test.integration
@@ -1,17 +1,7 @@
 #!/bin/bash
 
-# set -e # exit on error
-# set -x # echo commands
+set -e # exit on error
+set -x # echo commands
 
-if [[ -z "${SAUCE_USERNAME}" ]] || [[ -z "${SAUCE_ACCESS_KEY}" ]]; then
-    echo
-    echo "SKIPPING SAUCELABS TESTS --- external committer"
-    echo
-    exit 0
-fi
-
-py.test -m integration              \
-    --driver SauceLabs              \
-    --self-contained-html           \
-    --html=tests/pytest-report.html \
-    --upload -n4 -r3
+py.test -v -m integration \
+    --driver chrome
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,7 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/__init__.py
@@ -0,0 +1,7 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
diff --git a/tests/integration/tools/__init__.py b/tests/integration/tools/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/__init__.py
@@ -0,0 +1,7 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
diff --git a/tests/integration/tools/test_box_zoom_tool.py b/tests/integration/tools/test_box_zoom_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_box_zoom_tool.py
@@ -0,0 +1,244 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import BoxZoomTool, ColumnDataSource, CustomAction, CustomJS, PanTool, Plot, Range1d, Rect
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot(tool):
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=450, min_border_right=50, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(tool)
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_BoxZoomTool(object):
+
+    def test_deselected_by_default_with_pan_tool(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool())
+        plot.add_tools(PanTool())
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('box-zoom')
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_selected_by_default_without_pan_tool(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool())
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('box-zoom')
+        assert 'active' in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_can_be_selected_and_deselected(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool())
+        plot.add_tools(PanTool())
+
+        page = single_plot_page(plot)
+
+        # Check is not active
+        button = page.get_toolbar_button('box-zoom')
+        assert 'active' not in button.get_attribute('class')
+
+        # Click and check is active
+        button = page.get_toolbar_button('box-zoom')
+        button.click()
+        assert 'active' in button.get_attribute('class')
+
+        # Click again and check is not active
+        button = page.get_toolbar_button('box-zoom')
+        button.click()
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('dim', ['both', 'width', 'height'])
+    def test_box_zoom_has_no_effect_when_deslected(self, dim, single_plot_page):
+        plot = _make_plot(BoxZoomTool(dimensions=dim))
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('box-zoom')
+        button.click()
+
+        page.drag_canvas_at_position(100, 100, 20, 20)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_with_corner_origin(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool())
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(100, 100, 200, 200)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == pytest.approx(0.25)
+        assert results['xrend'] == pytest.approx(0.75)
+        assert results['yrstart'] == pytest.approx(0.25)
+        assert results['yrend'] == pytest.approx(0.75)
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_with_center_origin(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(origin="center"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(100, 100, 50, 50)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert (results['xrstart'] + results['xrend'])/2.0 == pytest.approx(0.25)
+        assert (results['yrstart'] + results['yrend'])/2.0 == pytest.approx(0.75)
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_with_center_origin_clips_to_range(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(origin="center"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(200, 200, 500, 500)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_width_updates_only_xrange(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(dimensions="width"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(250, 250, 50, 50)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] > 0.5
+        assert results['xrend'] < 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_width_clips_to_xrange(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(dimensions="width"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(250, 250, 500, 50)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] > 0.5
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_height_updates_only_yrange(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(dimensions="height"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(250, 250, 50, 50)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] > 0
+        assert results['yrend'] < 0.5
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_height_clips_to_yrange(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(dimensions="height"))
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(250, 250, 50, 500)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] < 0.5
+
+        assert page.has_no_console_errors()
+
+    def test_box_zoom_can_match_aspect(self, single_plot_page):
+        plot = _make_plot(BoxZoomTool(match_aspect=True))
+        plot.x_range.end = 2
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(150, 150, 70, 53)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert (results['xrend'] - results['xrstart']) / (results['yrend'] - results['yrstart']) == pytest.approx(2.0)
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_custom_action.py b/tests/integration/tools/test_custom_action.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_custom_action.py
@@ -0,0 +1,52 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import CustomAction, CustomJS
+from bokeh.plotting import figure
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_CustomAction(object):
+
+    def test_tap_triggers_callback(self, single_plot_page):
+        plot = figure(height=800, width=1000, tools='')
+        plot.rect(x=[1, 2], y=[1, 1], width=1, height=1)
+        plot.add_tools(CustomAction(callback=CustomJS(code=RECORD("activated", "true"))))
+
+        page = single_plot_page(plot)
+
+        page.click_custom_action()
+
+        assert page.results["activated"] == True
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_pan_tool.py b/tests/integration/tools/test_pan_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_pan_tool.py
@@ -0,0 +1,161 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomAction, CustomJS, Plot, Range1d, Rect, PanTool
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot(dimensions="both"):
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(PanTool(dimensions=dimensions))
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+_css = dict(both='pan', width='xpan', height='ypan')
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_PanTool(object):
+
+    @pytest.mark.parametrize('dim', ['both', 'width', 'height'])
+    def test_selected_by_default(self, dim, single_plot_page):
+        plot = _make_plot(dim)
+
+        page = single_plot_page(plot)
+
+        target = 'bk-tool-icon-%s' % _css[dim]
+
+        button = page.driver.find_element_by_class_name(target)
+        assert 'active' in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('dim', ['both', 'width', 'height'])
+    def test_can_be_deselected_and_selected(self, dim, single_plot_page):
+        plot = _make_plot(dim)
+
+        page = single_plot_page(plot)
+
+        target = 'bk-tool-icon-%s' % _css[dim]
+
+        # Check is active
+        button = page.driver.find_element_by_class_name(target)
+        assert 'active' in button.get_attribute('class')
+
+        # Click and check is not active
+        button = page.driver.find_element_by_class_name(target)
+        button.click()
+        assert 'active' not in button.get_attribute('class')
+
+        # Click again and check is active
+        button = page.driver.find_element_by_class_name(target)
+        button.click()
+        assert 'active' in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('dim', ['both', 'width', 'height'])
+    def test_pan_has_no_effect_when_deslected(self, dim, single_plot_page):
+        plot = _make_plot(dim)
+
+        page = single_plot_page(plot)
+
+        target = 'bk-tool-icon-%s' % _css[dim]
+
+        button = page.driver.find_element_by_class_name(target)
+        button.click()
+
+        page.drag_canvas_at_position(100, 100, 20, 20)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_pan_updates_both_ranges(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(100, 100, 20, 20)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] < 0
+        assert results['xrend'] < 1
+        assert results['yrstart'] > 0
+        assert results['yrend'] > 1
+
+        assert page.has_no_console_errors()
+
+    def test_xpan_upates_only_xrange(self, single_plot_page):
+        plot = _make_plot('width')
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(100, 100, 20, 20)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] < 0
+        assert results['xrend'] < 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_ypan_updates_only_yrange(self, single_plot_page):
+        plot = _make_plot('height')
+
+        page = single_plot_page(plot)
+
+        page.drag_canvas_at_position(100, 100, 20, 20)
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] > 0
+        assert results['yrend'] > 1
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_reset_tool.py b/tests/integration/tools/test_reset_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_reset_tool.py
@@ -0,0 +1,256 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import (
+    Circle, CategoricalScale, ColumnDataSource, CustomAction, CustomJS, DataRange1d, FactorRange, Plot,
+    Range1d, Rect, ResetTool, ZoomInTool)
+from bokeh._testing.util.selenium import COUNT, RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot():
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(ResetTool(), ZoomInTool())
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_ResetTool(object):
+
+    def test_deselected_by_default(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('reset')
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_clicking_resets_range(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        # Change the ranges using a zoom in tool
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] != 0
+        assert results['xrend'] != 1
+        assert results['yrstart'] != 0
+        assert results['yrend'] != 1
+
+        # Click the reset tool and check the ranges are restored
+        button = page.get_toolbar_button('reset')
+        button.click()
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_clicking_resets_selection(self, single_plot_page):
+        source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+        source.selected.indices = [0]
+        source.selected.line_indices = [0]
+
+        # XXX (bev) string key for multiline_indices seems questionable
+        source.selected.multiline_indices = {"0": [0]}
+
+        plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+        plot.add_glyph(source, Circle(x='x', y='y', size=20))
+        plot.add_tools(ResetTool())
+        code = \
+            RECORD("indices", "s.selected.indices") + \
+            RECORD("line_indices", "s.selected.line_indices") + \
+            RECORD("multiline_indices", "s.selected.multiline_indices")
+        plot.add_tools(CustomAction(callback=CustomJS(args=dict(s=source), code=code)))
+        plot.toolbar_sticky = False
+
+        page = single_plot_page(plot)
+
+        # Verify selections are non empty
+        page.click_custom_action()
+
+        results = page.results
+        assert results['indices'] == [0]
+        assert results['line_indices'] == [0]
+        assert results['multiline_indices'] == {"0": [0]} # XXX (bev) string key
+
+        # Click the reset tool and check the selections are restored
+        button = page.get_toolbar_button('reset')
+        button.click()
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['indices'] == []
+        assert results['line_indices'] == []
+        assert results['multiline_indices'] == {}
+
+        assert page.has_no_console_errors()
+
+    def test_reset_triggers_range1d_callbacks(self, single_plot_page):
+        source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+        plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+        plot.add_glyph(source, Circle(x='x', y='y', size=20))
+        plot.add_tools(ResetTool(), ZoomInTool())
+        plot.x_range.callback = CustomJS(code=COUNT("xrcb"))
+        plot.x_range.js_on_change('start', CustomJS(code=COUNT("xrstart")))
+        plot.x_range.js_on_change('end', CustomJS(code=COUNT("xrend")))
+        plot.y_range.callback = CustomJS(code=COUNT("yrcb"))
+        plot.y_range.js_on_change('start', CustomJS(code=COUNT("yrstart")))
+        plot.y_range.js_on_change('end', CustomJS(code=COUNT("yrend")))
+
+        page = single_plot_page(plot)
+
+        # Change the ranges using a zoom in tool
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        results = page.results
+        assert results['xrcb'] == 1
+        assert results['xrstart'] == 1
+        assert results['xrend'] == 1
+        assert results['yrcb'] == 1
+        assert results['yrstart'] == 1
+        assert results['yrend'] == 1
+
+        # Click the reset tool and check the callback was called
+        button = page.get_toolbar_button('reset')
+        button.click()
+
+        results = page.results
+        assert results['xrcb'] == 2
+        assert results['xrstart'] == 2
+        assert results['xrend'] == 2
+        assert results['yrcb'] == 2
+        assert results['yrstart'] == 2
+        assert results['yrend'] == 2
+
+        assert page.has_no_console_errors()
+
+    def test_reset_triggers_datarange1d_callbacks(self, single_plot_page):
+        source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+        plot = Plot(plot_height=400, plot_width=400, x_range=DataRange1d(), y_range=DataRange1d(), min_border=0)
+        plot.add_glyph(source, Circle(x='x', y='y', size=20))
+        plot.add_tools(ResetTool(), ZoomInTool())
+        plot.x_range.callback = CustomJS(code=COUNT("xrcb"))
+        plot.x_range.js_on_change('start', CustomJS(code=COUNT("xrstart")))
+        plot.x_range.js_on_change('end', CustomJS(code=COUNT("xrend")))
+        plot.y_range.callback = CustomJS(code=COUNT("yrcb"))
+        plot.y_range.js_on_change('start', CustomJS(code=COUNT("yrstart")))
+        plot.y_range.js_on_change('end', CustomJS(code=COUNT("yrend")))
+
+        page = single_plot_page(plot)
+
+        # Change the ranges using a zoom in tool
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        # Callbacks have "extra" invocations due to DataRange1d auto-ranging on init.
+
+        results = page.results
+        assert results['xrcb'] == 3
+        assert results['xrstart'] == 2
+        assert results['xrend'] == 2
+        assert results['yrcb'] == 3
+        assert results['yrstart'] == 2
+        assert results['yrend'] == 2
+
+        # Click the reset tool and check the callback was called
+        button = page.get_toolbar_button('reset')
+        button.click()
+
+        results = page.results
+        assert results['xrcb'] == 6
+        assert results['xrstart'] == 3
+        assert results['xrend'] == 3
+        assert results['yrcb'] == 6
+        assert results['yrstart'] == 3
+        assert results['yrend'] == 3
+
+        assert page.has_no_console_errors()
+
+    def test_reset_triggers_factorrange_callbacks(self, single_plot_page):
+        source = ColumnDataSource(dict(x=["a", "b"], y=["a", "a"]))
+        plot = Plot(plot_height=400, plot_width=400,
+                    x_scale=CategoricalScale(), x_range=FactorRange(factors=["a", "b", "c"]),
+                    y_scale=CategoricalScale(), y_range=FactorRange(factors=["a", "b", "c"]), min_border=0)
+        plot.add_glyph(source, Circle(x='x', y='y', size=20))
+        plot.add_tools(ResetTool(), ZoomInTool())
+        plot.x_range.callback = CustomJS(code=COUNT("xrcb"))
+        plot.x_range.js_on_change('start', CustomJS(code=COUNT("xrstart")))
+        plot.x_range.js_on_change('end', CustomJS(code=COUNT("xrend")))
+        plot.y_range.callback = CustomJS(code=COUNT("yrcb"))
+        plot.y_range.js_on_change('start', CustomJS(code=COUNT("yrstart")))
+        plot.y_range.js_on_change('end', CustomJS(code=COUNT("yrend")))
+
+        page = single_plot_page(plot)
+
+        # Change the ranges using a zoom in tool
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        results = page.results
+        assert results['xrcb'] == 1
+        assert results['xrstart'] == 1
+        assert results['xrend'] == 1
+        assert results['yrcb'] == 1
+        assert results['yrstart'] == 1
+        assert results['yrend'] == 1
+
+        # Click the reset tool and check the callback was called
+        button = page.get_toolbar_button('reset')
+        button.click()
+
+        results = page.results
+        assert results['xrcb'] == 3
+        assert results['xrstart'] == 2
+        assert results['xrend'] == 2
+        assert results['yrcb'] == 3
+        assert results['yrstart'] == 2
+        assert results['yrend'] == 2
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_tap_tool.py b/tests/integration/tools/test_tap_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_tap_tool.py
@@ -0,0 +1,86 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import TapTool, CustomAction, CustomJS
+from bokeh.plotting import figure
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+# TODO (bev):
+#
+# check that .names is respected
+# check that .renderers is respected
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_TapTool(object):
+
+    def test_tap_triggers_no_callback_without_hit(self, single_plot_page):
+        plot = figure(height=800, width=1000, tools='')
+        plot.rect(x=[1, 2], y=[1, 1], width=1, height=1)
+        plot.add_tools(TapTool(callback=CustomJS(code=RECORD("indices", "cb_data.source.selected.indices"))))
+        plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=RECORD("junk", "10"))))
+
+        page = single_plot_page(plot)
+
+        # make sure no indicies (even an empty list) was recorded
+        page.click_canvas_at_position(50, 50)
+        page.click_custom_action()
+        assert page.results == {"junk": 10}
+
+        assert page.has_no_console_errors()
+
+    def test_tap_triggers_callback_with_indices(self, single_plot_page):
+        plot = figure(height=800, width=1000, tools='')
+        plot.rect(x=[1, 2], y=[1, 1], width=1, height=1)
+        plot.add_tools(TapTool(callback=CustomJS(code=RECORD("indices", "cb_data.source.selected.indices"))))
+
+        page = single_plot_page(plot)
+
+        page.click_canvas_at_position(400, 500)
+        assert page.results["indices"] == [0]
+
+        page.click_canvas_at_position(600, 300)
+        assert page.results["indices"] == [1]
+
+        assert page.has_no_console_errors()
+
+    def test_tap_reports_all_indices_on_overlap(self, single_plot_page):
+        plot = figure(height=800, width=1000, tools='')
+        plot.rect(x=[1, 1], y=[1, 1], width=1, height=1)
+        plot.add_tools(TapTool(callback=CustomJS(code=RECORD("indices", "cb_data.source.selected.indices"))))
+
+        page = single_plot_page(plot)
+
+        page.click_canvas_at_position(400, 500)
+        assert set(page.results["indices"]) == {0, 1}
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_wheel_zoom_tool.py b/tests/integration/tools/test_wheel_zoom_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_wheel_zoom_tool.py
@@ -0,0 +1,227 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomAction, CustomJS, Plot, Range1d, Rect, WheelZoomTool
+from bokeh._testing.util.selenium import RECORD, SCROLL
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot(dimensions="both"):
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(WheelZoomTool(dimensions=dimensions))
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_WheelZoomTool(object):
+
+    def test_deselected_by_default(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('wheel-zoom')
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_can_be_selected_and_deselected(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        # Check is not active
+        button = page.get_toolbar_button('wheel-zoom')
+        assert 'active' not in button.get_attribute('class')
+
+        # Click and check is active
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+        assert 'active' in button.get_attribute('class')
+
+        # Click again and check is not active
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_zoom_out(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        # First check that scrolling has no effect before the tool is activated
+        page.driver.execute_script(SCROLL(200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        # Next check that scrolling adjusts the range after the tool is activated
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+
+        page.driver.execute_script(SCROLL(200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] < 0
+        assert results['xrend'] > 1
+        assert results['yrstart'] < 0
+        assert results['yrend'] > 1
+
+        assert page.has_no_console_errors()
+
+    def test_zoom_in(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        # First check that scrolling has no effect before the tool is activated
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        # Next check that scrolling adjusts the range after the tool is activated
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] > 0
+        assert results['xrend'] < 1
+        assert results['yrstart'] > 0
+        assert results['yrend'] < 1
+
+        assert page.has_no_console_errors()
+
+    def test_xwheel_zoom(self, single_plot_page):
+        plot = _make_plot(dimensions="width")
+
+        page = single_plot_page(plot)
+
+        # First check that scrolling has no effect before the tool is activated
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        # Next check that scrolling adjusts the x range after the tool is activated
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] > 0
+        assert results['xrend'] < 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        page.driver.execute_script(SCROLL(400))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] < 0
+        assert results['xrend'] > 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        assert page.has_no_console_errors()
+
+    def test_ywheel_zoom(self, single_plot_page):
+        plot = _make_plot(dimensions="height")
+
+        page = single_plot_page(plot)
+
+        # First check that scrolling has no effect before the tool is activated
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] == 0
+        assert results['yrend'] == 1
+
+        # Next check that scrolling adjusts the y range after the tool is activated
+        button = page.get_toolbar_button('wheel-zoom')
+        button.click()
+
+        page.driver.execute_script(SCROLL(-200))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] > 0
+        assert results['yrend'] < 1
+
+        page.driver.execute_script(SCROLL(400))
+
+        page.click_custom_action()
+
+        results = page.results
+        assert results['xrstart'] == 0
+        assert results['xrend'] == 1
+        assert results['yrstart'] < 0
+        assert results['yrend'] > 1
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_zoom_in_tool.py b/tests/integration/tools/test_zoom_in_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_zoom_in_tool.py
@@ -0,0 +1,87 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomAction, CustomJS, Plot, Range1d, Rect, ZoomInTool
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot():
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(ZoomInTool())
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_ZoomInTool(object):
+
+    def test_deselected_by_default(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('zoom-in')
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_clicking_zooms_in(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        page.click_custom_action()
+
+        first = page.results
+        assert first['xrstart'] > 0
+        assert first['xrend'] < 1
+        assert first['yrstart'] > 0
+        assert first['yrend'] < 1
+
+        button = page.get_toolbar_button('zoom-in')
+        button.click()
+
+        page.click_custom_action()
+
+        second = page.results
+        assert second['xrstart'] > first['xrstart']
+        assert second['xrend'] < first['xrend']
+        assert second['yrstart'] > first['yrstart']
+        assert second['yrend'] < first['yrend']
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/tools/test_zoom_out_tool.py b/tests/integration/tools/test_zoom_out_tool.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/tools/test_zoom_out_tool.py
@@ -0,0 +1,87 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomAction, CustomJS, Plot, Range1d, Rect, ZoomOutTool
+from bokeh._testing.util.selenium import RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+def _make_plot():
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Rect(x='x', y='y', width=0.9, height=0.9))
+    plot.add_tools(ZoomOutTool())
+    code = RECORD("xrstart", "p.x_range.start") + RECORD("xrend", "p.x_range.end") + RECORD("yrstart", "p.y_range.start") + RECORD("yrend", "p.y_range.end")
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(p=plot), code=code)))
+    plot.toolbar_sticky = False
+    return plot
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_ZoomOutTool(object):
+
+    def test_deselected_by_default(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('zoom-out')
+        assert 'active' not in button.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_clicking_zooms_out(self, single_plot_page):
+        plot = _make_plot()
+
+        page = single_plot_page(plot)
+
+        button = page.get_toolbar_button('zoom-out')
+        button.click()
+
+        page.click_custom_action()
+
+        first = page.results
+        assert first['xrstart'] < 0
+        assert first['xrend'] > 1
+        assert first['yrstart'] < 0
+        assert first['yrend'] > 1
+
+        button = page.get_toolbar_button('zoom-out')
+        button.click()
+
+        page.click_custom_action()
+
+        second = page.results
+        assert second['xrstart'] < first['xrstart']
+        assert second['xrend'] > first['xrend']
+        assert second['yrstart'] < first['yrstart']
+        assert second['yrend'] > first['yrend']
+
+        assert page.has_no_console_errors()
diff --git a/tests/integration/widgets/__init__.py b/tests/integration/widgets/__init__.py
new file mode 100644
diff --git a/tests/integration/widgets/tables/__init__.py b/tests/integration/widgets/tables/__init__.py
new file mode 100644
diff --git a/tests/integration/widgets/tables/test_cell_editors.py b/tests/integration/widgets/tables/test_cell_editors.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/widgets/tables/test_cell_editors.py
@@ -0,0 +1,380 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.models import ColumnDataSource, CustomJS, DataTable, IntEditor, NumberEditor, StringEditor, TableColumn
+from bokeh._testing.util.selenium import enter_text_in_cell, get_table_cell, RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_CellEditor_Base(object):
+
+    def setup_method(self):
+        source = ColumnDataSource({'values': self.values})
+        column = TableColumn(field='values', title='values', editor=self.editor())
+        self.table = DataTable(source=source, columns=[column], editable=True, width=600)
+
+        # this is triggered on selection changes
+        source.selected.js_on_change('indices', CustomJS(args=dict(s=source), code=RECORD("values", "s.data.values")))
+
+# XXX Chekbox editor is currently completely broken
+# class Test_CheckboxEditor(Test_CellEditor_Base):
+
+#     values = [True, False]
+#     editor = CheckboxEditor
+
+#     def test_editing_does_not_update_source_on_noneditable_table(self, bokeh_model_page):
+#         self.table.editable = False
+#         page = bokeh_model_page(self.table)
+
+#         # Click row 1 (which triggers the selection callback)
+#         cell = get_table_cell(page.driver, 1, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         # Now double click, enter the text new value and <enter>
+#         cell = get_table_cell(page.driver, 1, 1)
+#         # TODO
+
+#         # Click row 2 (which triggers callback again so we can inspect the data)
+#         cell = get_table_cell(page.driver, 2, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         assert page.has_no_console_errors()
+
+#     def test_editing_updates_source(self, bokeh_model_page):
+#         page = bokeh_model_page(self.table)
+
+#         # Click row 1 (which triggers the selection callback)
+#         cell = get_table_cell(page.driver, 1, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         # Now double click, enter the text new value and <enter>
+#         cell = get_table_cell(page.driver, 1, 1)
+#         # TODO
+
+#         # Click row 2 (which triggers callback again so we can inspect the data)
+#         cell = get_table_cell(page.driver, 2, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == [False, False]
+
+#         assert page.has_no_console_errors()
+
+class Test_IntEditor(Test_CellEditor_Base):
+
+    values = [1, 2]
+    editor = IntEditor
+
+    def test_editing_does_not_update_source_on_noneditable_table(self, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "33")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('bad', ["1.1", "text"])
+    def test_editing_does_not_update_source_on_bad_values(self, bad, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, bad)
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    def test_editing_updates_source(self, bokeh_model_page):
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "33")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == [33, 2]
+
+        assert page.has_no_console_errors()
+
+class Test_NumberEditor(Test_CellEditor_Base):
+
+    values = [1.1, 2.2]
+    editor = NumberEditor
+
+    def test_editing_does_not_update_source_on_noneditable_table(self, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "33.5")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('bad', ["text"])
+    def test_editing_does_not_update_source_on_bad_values(self, bad, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, bad)
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    def test_editing_updates_source(self, bokeh_model_page):
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "33.5")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == [33.5, 2.2]
+
+        assert page.has_no_console_errors()
+
+class Test_StringEditor(Test_CellEditor_Base):
+
+    values = ["foo", "bar"]
+    editor = StringEditor
+
+    def test_editing_does_not_update_source_on_noneditable_table(self, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "baz")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    @pytest.mark.parametrize('bad', ["1", "1.1", "-1"])
+    def test_editing_does_not_update_source_on_bad_values(self, bad, bokeh_model_page):
+        self.table.editable = False
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, bad)
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        assert page.has_no_console_errors()
+
+    def test_editing_updates_source(self, bokeh_model_page):
+        page = bokeh_model_page(self.table)
+
+        # Click row 1 (which triggers the selection callback)
+        cell = get_table_cell(page.driver, 1, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == self.values
+
+        # Now double click, enter the text new value and <enter>
+        cell = get_table_cell(page.driver, 1, 1)
+        enter_text_in_cell(page.driver, cell, "baz")
+
+        # Click row 2 (which triggers callback again so we can inspect the data)
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+        results = page.results
+        assert results['values'] == ["baz", "bar"]
+
+        assert page.has_no_console_errors()
+
+# XXX (bev) PercentEditor is currently completely broken
+# class Test_PercentEditor(Test_CellEditor_Base):
+
+#     values = [0.1, 0.2]
+#     editor = PercentEditor
+
+#     def test_editing_does_not_update_source_on_noneditable_table(self, bokeh_model_page):
+#         self.table.editable = False
+#         page = bokeh_model_page(self.table)
+
+#         # Click row 1 (which triggers the selection callback)
+#         cell = get_table_cell(page.driver, 1, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         # Now double click, enter the text 33 and <enter>
+#         cell = get_table_cell(page.driver, 1, 1)
+#         enter_text_in_cell(page.driver, cell, "0.5")
+
+#         # Click row 2 (which triggers callback again so we can inspect the data)
+#         cell = get_table_cell(page.driver, 2, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         assert page.has_no_console_errors()
+
+#     @pytest.mark.parametrize('bad', ["-1", "-0.5", "1.1", "2", "text"])
+#     def test_editing_does_not_update_source_on_bad_values(self, bad, bokeh_model_page):
+#         self.table.editable = False
+#         page = bokeh_model_page(self.table)
+
+#         # Click row 1 (which triggers the selection callback)
+#         cell = get_table_cell(page.driver, 1, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         # Now double click, enter the text new value and <enter>
+#         cell = get_table_cell(page.driver, 1, 1)
+#         enter_text_in_cell(page.driver, cell, bad)
+
+#         # Click row 2 (which triggers callback again so we can inspect the data)
+#         cell = get_table_cell(page.driver, 2, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         assert page.has_no_console_errors()
+
+#     def test_editing_updates_source(self, bokeh_model_page):
+#         page = bokeh_model_page(self.table)
+
+#         # click row 1 (which triggers the selection callback)
+#         cell = get_table_cell(page.driver, 1, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == self.values
+
+#         # now double click, enter the text 33 and <enter>
+#         cell = get_table_cell(page.driver, 1, 1)
+#         enter_text_in_cell(page.driver, cell, "0.5")
+
+#         # click row 2 (which triggers callback again so we can inspect the data)
+#         cell = get_table_cell(page.driver, 2, 1)
+#         cell.click()
+#         results = page.results
+#         assert results['values'] == [0.5, 0.2]
+
+#         assert page.has_no_console_errors()
diff --git a/tests/integration/widgets/tables/test_data_table.py b/tests/integration/widgets/tables/test_data_table.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/widgets/tables/test_data_table.py
@@ -0,0 +1,137 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+
+# External imports
+
+# Bokeh imports
+from bokeh.layouts import column
+from bokeh.models import ColumnDataSource, CustomJS, DataTable, TableColumn
+from bokeh._testing.util.selenium import ButtonWrapper, get_table_cell, RECORD
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.bokeh",
+)
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_CellEditor_Base(object):
+
+    def setup_method(self):
+        source = ColumnDataSource({'values': self.values})
+        column = TableColumn(field='values', title='values', editor=self.editor())
+        self.table = DataTable(source=source, columns=[column], editable=True, width=600)
+
+        # this is triggered on selection changes
+        source.selected.js_on_change('indices', CustomJS(args=dict(s=source), code=RECORD("values", "s.data.values")))
+
+@pytest.mark.integration
+@pytest.mark.selenium
+class Test_Datatable(object):
+
+    def test_row_highlights_reflect_no_initial_selection(self, bokeh_model_page):
+
+        source = ColumnDataSource({'values': [1, 2]})
+        column = TableColumn(field='values', title='values')
+        table = DataTable(source=source, columns=[column], editable=False, width=600)
+
+        page = bokeh_model_page(table)
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' not in row1.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_row_highlights_reflect_initial_selection(self, bokeh_model_page):
+
+        source = ColumnDataSource({'values': [1, 2]})
+        source.selected.indices = [1]
+        column = TableColumn(field='values', title='values')
+        table = DataTable(source=source, columns=[column], editable=False, width=600)
+
+        page = bokeh_model_page(table)
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' in row1.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_row_highlights_reflect_ui_selection(self, bokeh_model_page):
+
+        source = ColumnDataSource({'values': [1, 2]})
+        column = TableColumn(field='values', title='values')
+        table = DataTable(source=source, columns=[column], editable=False, width=600)
+
+        page = bokeh_model_page(table)
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' not in row1.get_attribute('class')
+
+        cell = get_table_cell(page.driver, 2, 1)
+        cell.click()
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' in row1.get_attribute('class')
+
+        assert page.has_no_console_errors()
+
+    def test_row_highlights_reflect_js_selection(self, bokeh_model_page):
+
+        source = ColumnDataSource({'values': [1, 2]})
+        col = TableColumn(field='values', title='values')
+        table = DataTable(source=source, columns=[col], editable=False, width=600)
+
+        button = ButtonWrapper("Click", callback=CustomJS(args=dict(s=source), code="""
+            s.selected.indices = [1]
+        """))
+
+        page = bokeh_model_page(column(button.obj, table))
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' not in row1.get_attribute('class')
+
+        button.click(page.driver)
+
+        row0 = get_table_cell(page.driver, 1, 1)
+        assert 'selected' not in row0.get_attribute('class')
+
+        row1 = get_table_cell(page.driver, 2, 1)
+        assert 'selected' in row1.get_attribute('class')
+
+        assert page.has_no_console_errors()