Repository: bokeh/bokeh

Issue:
[BUG] DateRangeSlider is broken
### ALL software version info (bokeh, python, notebook, OS, browser, any other relevant packages)
Bokeh 2.0.0dev6 (ff2d2f62)

#### Description of expected behavior and the observed behavior
If `start` is set to today, the slider is not displayed at all. The JS exception:
```
Error rendering Bokeh items: TypeError: Cannot read property '0' of null
    at nouislider.js:298
    at Array.sort (<anonymous>)
    at new Spectrum (nouislider.js:298)
    at Object.testRange [as t] (nouislider.js:392)
    at nouislider.js:711
    at Array.forEach (<anonymous>)
    at testOptions (nouislider.js:703)
    at Object.initialize [as create] (nouislider.js:1770)
    at DateRangeSliderView.render (abstract_slider.js:105)
    at DateRangeSliderView.build (layout_dom.js:119)
```
It's caused by the `start` value being `null` on the JS side. The relevant `attributes` value in the `PULL-DOC-REPLY` message:
```
{end: 1583107200000, value: ["2020-02-21", "2020-03-02"]}
```


If `start` is set to any other day, the slider is displayed, but incorrectly - it doesn't depict the actual range. There's only one handle at the very start. Any attempt to move it leads to the displayed value set to
```
function () { return request.convert(arguments); } .. function () { return request.convert(arguments); }
```
and the exception:
```
ValueError: expected an element of Tuple(Datetime, Datetime), got (None, None)
```
It's caused by an incorrect update to the `value` field. One of the corresponding WS messages:
```
{"events":[{"kind":"ModelChanged","model":{"id":"1041"},"attr":"value","new":[null,null]}],"references":[]}
```

#### Complete, minimal, self-contained example code that reproduces the issue
```python
from datetime import date, timedelta

from bokeh.io import curdoc
from bokeh.models import DateRangeSlider

start = date.today() - timedelta(days=1)
end = start + timedelta(days=10)
beginning = DateRangeSlider(value=(start, end), start=start, end=end)

curdoc().add_root(beginning)
```



Discussion:
@p-himik  any idea when  this broke or what change caused it? I thought we had integration tests on date slider but apparently not.
Not a clue. I just did a quick check for a SO question and found out that it works on 1.4.0 but fails on 2.0.0dev6.
Yeah, there are only Python tests for the slider.
OK, if you have a chance to do any sort of bisection that'd be helpful otherwise I will try to dig in to it tonight.
I'll try to do some bisecting - a good opportunity to actually use `git bisect` for the first time. :)
`git bisect` is a great tool, I literally always refer back to this howto page every time I need to use it though http://webchick.net/node/99
Yeah, that's the resource I used.

Why did you change `Date` to `Datetime` here? https://github.com/bokeh/bokeh/commit/16d3d0ef
Because `Date` is now implemented with an unambiguous ISO date string, and is no longer represented by a timestamp. That change was made to put a stop to the endless years of time zone issues that plagued the date picker. But `DateRangeSlider` has always expected a timestamp of sorts, so its properties could not remain `Date` any longer. I did check `DateRangeSlider` at some points during that PR, but maybe there were issues I missed with the change. 
Hmm. Now it seems strange that we have a **Date**RangeSlider but it accepts datetimes.
From the API standpoint, I think it would make sense to have both `DateRangeSlider` and `DatetimeRangeSlider`. But that's a separate matter.

Another question - is this really a desired thing to make this function return a number instead of an instance of `datetime`? https://github.com/bokeh/bokeh/blob/03618bf8a6233303574e846d70a238f891a1a93a/bokeh/util/serialization.py#L134-L144
> Now it seems strange that we have a DateRangeSlider but it accepts datetimes.

I don't disagree, getting the new DatePicker working with minimal other change was all I had bandwidth for. Though I think "DateRangeSlider" has generally been implicitly understood to be a range slider for full date times (and afaik that is how people use it). I think really cleaning this up is probably Bokeh 3.0 territory at this point though, we should just get the previous functionality working again and file issues for the future. 

>  is this really a desired thing to make this function return a number instead of an instance of datetime?

Practically speaking that's a totally internal function if you want to rename it to `convert_date_to_datetime` to `convert_date_to_timestamp` that's fine with me.
> I think really cleaning this up is probably Bokeh 3.0 territory

Agree. To be honest, I hate handling anything time-related at all, so if nobody complains about it I'd leave it as is.

> if you want to rename it [...] that's fine with me

Sorry, I didn't make it clear enough. All the other functions that convert input values for `Datetime` do return an instance of `datetime`.
And indeed, changing the function to return just `dt.datetime(*obj.timetuple()[:6])` seems to fix the issue.
> And indeed, changing the function to return just dt.datetime(*obj.timetuple()[:6]) seems to fix the issue.

Well, that change is fine too though I would expect some other changes are necessary? Or is this function just not being used anywhere at all?
This function is used to confirm that the `Datetime` property properly transforms to a timestamp:

https://github.com/bokeh/bokeh/search?utf8=✓&q=convert_date_to_datetime&type=

So if we change it we would also need to add back a timestamp version and have the test use that. 
Not sure whether it affects TZ issues in any way though.
The current implementation also seems shaky w.r.t. TZs. We accept a proper `datetime` but strip away any TZ information and just send away a float, and receive a float.
But that's just voicing a concern, nothing more - as I mentioned, I'm happy to not have to touch it. :)

Speaking of which - do you mind if I offload this to you? Playing Miss Marple is fun right until the archenemy named timezones appears. And you at least seem to know and remember a lot more about the background of this than I do.
Sure, FYI we try to be tz-naive i.e. treat all datetime values as local and leave it to users to convert as necessary for what they want to show. The issue with the old date picker was that we could not prevent a conversion that it did internally which really mucked with that assumption. 
@p-himik I actually can't reproduce any issue with your code on my laptop with master (with either `today` or "today-1") can you add more detail about platform, etc?
FYI Here is the updated test script I am using:
```
from datetime import date, timedelta, datetime

from bokeh.io import curdoc
from bokeh.models import DateRangeSlider

start = date.today() # - timedelta(days=1)
end = start + timedelta(days=10)
beginning = DateRangeSlider(value=(start, end), start=start, end=end)

def callback(attr, old, new):
    print(beginning.value_as_date)
beginning.on_change('value', callback)

curdoc().add_root(beginning)
```
It always prints out the expected console output and I am not seeing any errors there or in the browser (FF, Safari, Chrome on OSX)
Wait, WTH just happened? Now it works for me as well, both for the current master and for the branch mentioned in the beginning!
I now always remove the `bokehjs/build` directory before building BokehJS. And I did `git bisect`, and I could reproduce it _all the time_ after your commit that replaced `Date` with `Datetime`.

Magic. Love me some reverse schroedinbugs.

FWIW, I'm on Ubuntu 19.10, latest Google Chrome.
Phew, false alarm - no magic here. I'm sleepy and I forgot that I replaced `date.today()` with `datetime.now()` to test something else.

Still breaks for me. Your code is breaking for me as well.
When you tried to reproduce it, did you make sure that the built BokehJS is in sync?
@p-himik Weird/annoying: my env path was wrong. I think there is a bug in conda deactivate (I deactivated from "140" env to "dev" env  and path ended up on base env...) I can repro now
Heh, I hit a similar issue while bisecting. It seems I had used `python setup.py install` at some point, which made me always use some random Bokeh version.
OK I have a handle on this. It basically boils down to value being a tuple, and container properties not being careful to transform their contained values, so the tuple of actual date objects hits the JSONEncoder, which serializes date objects as ISO string. I will have to think a bit about the best/minimal way to resolve this. I should possibly d have a PR tomorrow but possibly not until the afternoon.
@p-himik  if you have a chance, here is a diff you could try out
```
diff --git a/bokeh/core/property/container.py b/bokeh/core/property/container.py
index 9907784d8..97249ac5c 100644
--- a/bokeh/core/property/container.py
+++ b/bokeh/core/property/container.py
@@ -299,9 +299,17 @@ class Tuple(ContainerProperty):
                 msg = "" if not detail else "expected an element of %s, got %r" % (self, value)
                 raise ValueError(msg)

+    def serialize_value(self, value):
+        ''' Change the value into a JSON serializable format.
+
+        '''
+        return tuple(typ.serialize_value(x) for (typ, x) in zip(self.type_params, value))
+
     def _sphinx_type(self):
         return self._sphinx_prop_link() + "( %s )" % ", ".join(x._sphinx_type() for x in self.type_params)

+
+
 class RelativeDelta(Dict):
     ''' Accept RelativeDelta dicts for time delta values.

diff --git a/bokeh/core/property/datetime.py b/bokeh/core/property/datetime.py
index da56b6388..e9f901985 100644
--- a/bokeh/core/property/datetime.py
+++ b/bokeh/core/property/datetime.py
@@ -86,7 +86,7 @@ class Datetime(Property):

     '''

-    def __init__(self, default=datetime.date.today(), help=None):
+    def __init__(self, default=None, help=None):
         super().__init__(default=default, help=help)

     def transform(self, value):
@@ -96,7 +96,7 @@ class Datetime(Property):
             value = dateutil.parser.parse(value)

         elif Datetime.is_timestamp(value):
-            value = datetime.date.fromtimestamp(value)
+            value = datetime.date.fromtimestamp(value/1000)

         # Handled by serialization in protocol.py for now, except for Date
         if isinstance(value, datetime.date):
@@ -107,6 +107,9 @@ class Datetime(Property):
     def validate(self, value, detail=True):
         super().validate(value, detail)

+        if value is None:
+            return
+
         if is_datetime_type(value):
             return

@@ -126,6 +129,14 @@ class Datetime(Property):
         msg = "" if not detail else f"Expected a date, datetime object, or timestamp, got {value!r}"
         raise ValueError(msg)

+    def serialize_value(self, value):
+        ''' Change the value into a JSON serializable format.
+
+        '''
+        if isinstance(value, datetime.date):
+            value = convert_date_to_datetime(value)
+        return value
+
     @staticmethod
     def is_timestamp(value):
         return isinstance(value, (float,) + bokeh_integer_types) and not isinstance(value, bool)
diff --git a/bokeh/models/widgets/sliders.py b/bokeh/models/widgets/sliders.py
index 53bf21637..54af23982 100644
--- a/bokeh/models/widgets/sliders.py
+++ b/bokeh/models/widgets/sliders.py
@@ -152,6 +152,38 @@ class RangeSlider(AbstractSlider):
 class DateSlider(AbstractSlider):
     """ Slider-based date selection widget. """

+    @property
+    def value_as_datetime(self):
+        ''' Convenience property to retrieve the value tuple as a tuple of
+        datetime objects.
+
+        '''
+        if self.value is None:
+            return None
+
+        if isinstance(self.value, numbers.Number):
+            dt = datetime.utcfromtimestamp(v1 / 1000)
+        else:
+            dt = self.value
+        return dt
+
+    @property
+    def value_as_date(self):
+        ''' Convenience property to retrieve the value tuple as a tuple of
+        date objects.
+
+        Added in version 1.1
+        '''
+        if self.value is None:
+            return None
+
+        if isinstance(self.value, numbers.Number):
+            dt = datetime.utcfromtimestamp(self.value / 1000)
+            d = date(*dt.timetuple()[:3])
+        else:
+            d = self.value
+        return d
+
     value = Datetime(help="""
     Initial or selected value.
     """)
@@ -194,7 +226,7 @@ class DateRangeSlider(AbstractSlider):
             d2 = datetime.utcfromtimestamp(v2 / 1000)
         else:
             d2 = v2
-        return d1, d2    \
+        return d1, d2

     @property
     def value_as_date(self):
```

This change:

* only affects tuples (worried about performance implications of a general change to all containers)
* makes datetime properties nullable (which is true for basically every other property), which lets us not have "today" as a default value (which is kind of weird)
Noting: do need to resolve the timestamp/1000 business. We could put back the ugly try/except we had before but maybe there is a way to just fix it for real and always report the timestamp in units that can be known ahead. 

Resolving patch:
diff --git a/bokeh/core/property/container.py b/bokeh/core/property/container.py
--- a/bokeh/core/property/container.py
+++ b/bokeh/core/property/container.py
@@ -299,9 +299,29 @@ def validate(self, value, detail=True):
                 msg = "" if not detail else "expected an element of %s, got %r" % (self, value)
                 raise ValueError(msg)
 
+    def transform(self, value):
+        ''' Change the value into a JSON serializable format.
+
+        '''
+        if value is None:
+            return None
+
+        return tuple(typ.transform(x) for (typ, x) in zip(self.type_params, value))
+
+    def serialize_value(self, value):
+        ''' Change the value into a JSON serializable format.
+
+        '''
+        if value is None:
+            return None
+
+        return tuple(typ.serialize_value(x) for (typ, x) in zip(self.type_params, value))
+
     def _sphinx_type(self):
         return self._sphinx_prop_link() + "( %s )" % ", ".join(x._sphinx_type() for x in self.type_params)
 
+
+
 class RelativeDelta(Dict):
     ''' Accept RelativeDelta dicts for time delta values.
 
diff --git a/bokeh/core/property/datetime.py b/bokeh/core/property/datetime.py
--- a/bokeh/core/property/datetime.py
+++ b/bokeh/core/property/datetime.py
@@ -86,7 +86,7 @@ class Datetime(Property):
 
     '''
 
-    def __init__(self, default=datetime.date.today(), help=None):
+    def __init__(self, default=None, help=None):
         super().__init__(default=default, help=help)
 
     def transform(self, value):
@@ -95,9 +95,6 @@ def transform(self, value):
         if isinstance(value, str):
             value = dateutil.parser.parse(value)
 
-        elif Datetime.is_timestamp(value):
-            value = datetime.date.fromtimestamp(value)
-
         # Handled by serialization in protocol.py for now, except for Date
         if isinstance(value, datetime.date):
             value = convert_date_to_datetime(value)
@@ -107,6 +104,9 @@ def transform(self, value):
     def validate(self, value, detail=True):
         super().validate(value, detail)
 
+        if value is None:
+            return
+
         if is_datetime_type(value):
             return
 
@@ -126,6 +126,14 @@ def validate(self, value, detail=True):
         msg = "" if not detail else f"Expected a date, datetime object, or timestamp, got {value!r}"
         raise ValueError(msg)
 
+    def serialize_value(self, value):
+        ''' Change the value into a JSON serializable format.
+
+        '''
+        if isinstance(value, datetime.date):
+            value = convert_date_to_datetime(value)
+        return value
+
     @staticmethod
     def is_timestamp(value):
         return isinstance(value, (float,) + bokeh_integer_types) and not isinstance(value, bool)
diff --git a/bokeh/models/widgets/sliders.py b/bokeh/models/widgets/sliders.py
--- a/bokeh/models/widgets/sliders.py
+++ b/bokeh/models/widgets/sliders.py
@@ -152,6 +152,35 @@ class RangeSlider(AbstractSlider):
 class DateSlider(AbstractSlider):
     """ Slider-based date selection widget. """
 
+    @property
+    def value_as_datetime(self):
+        ''' Convenience property to retrieve the value as a datetime object.
+
+        Added in version 2.0
+        '''
+        if self.value is None:
+            return None
+
+        if isinstance(self.value, numbers.Number):
+            return datetime.utcfromtimestamp(self.value / 1000)
+
+        return self.value
+
+    @property
+    def value_as_date(self):
+        ''' Convenience property to retrieve the value as a date object.
+
+        Added in version 2.0
+        '''
+        if self.value is None:
+            return None
+
+        if isinstance(self.value, numbers.Number):
+            dt = datetime.utcfromtimestamp(self.value / 1000)
+            return date(*dt.timetuple()[:3])
+
+        return self.value
+
     value = Datetime(help="""
     Initial or selected value.
     """)
@@ -182,6 +211,7 @@ def value_as_datetime(self):
         ''' Convenience property to retrieve the value tuple as a tuple of
         datetime objects.
 
+        Added in version 1.1
         '''
         if self.value is None:
             return None
@@ -194,7 +224,7 @@ def value_as_datetime(self):
             d2 = datetime.utcfromtimestamp(v2 / 1000)
         else:
             d2 = v2
-        return d1, d2    \
+        return d1, d2
 
     @property
     def value_as_date(self):


Test patch:
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
@@ -37,12 +37,17 @@
     'ButtonWrapper',
     'copy_table_rows',
     'COUNT',
+    'drag_range_slider',
+    'drag_slider',
     'element_to_finish_resizing',
     'element_to_start_resizing',
     'enter_text_in_cell',
     'enter_text_in_cell_with_click_enter',
     'enter_text_in_element',
     'get_page_element',
+    'get_slider_bar_color',
+    'get_slider_title_text',
+    'get_slider_title_value',
     'get_table_cell',
     'get_table_column_cells',
     'get_table_header',
@@ -260,6 +265,40 @@ def wait_for_canvas_resize(canvas, test_driver):
         # try and process.
         pass
 
+def drag_slider(driver, css_class, distance, release=True):
+    el = driver.find_element_by_css_selector(css_class)
+    handle = el.find_element_by_css_selector('.bk-noUi-handle')
+    actions = ActionChains(driver)
+    actions.move_to_element(handle)
+    actions.click_and_hold()
+    actions.move_by_offset(distance, 0)
+    if release:
+        actions.release()
+    actions.perform()
+
+def drag_range_slider(driver, css_class, location, distance):
+    el = driver.find_element_by_css_selector(css_class)
+    handle = el.find_element_by_css_selector('.bk-noUi-handle-' + location)
+    actions = ActionChains(driver)
+    actions.move_to_element(handle)
+    actions.click_and_hold()
+    actions.move_by_offset(distance, 0)
+    actions.release()
+    actions.perform()
+
+def get_slider_title_text(driver, css_class):
+    el = driver.find_element_by_css_selector(css_class)
+    return el.find_element_by_css_selector('div.bk-input-group > div.bk-slider-title').text
+
+def get_slider_title_value(driver, css_class):
+    el = driver.find_element_by_css_selector(css_class)
+    return el.find_element_by_css_selector('div.bk-input-group > div > span.bk-slider-value').text
+
+def get_slider_bar_color(driver, css_class):
+    el = driver.find_element_by_css_selector(css_class)
+    bar = el.find_element_by_css_selector('.bk-noUi-connect')
+    return bar.value_of_css_property('background-color')
+
 #-----------------------------------------------------------------------------
 # Dev API
 #-----------------------------------------------------------------------------
diff --git a/tests/integration/widgets/test_daterange_slider.py b/tests/integration/widgets/test_daterange_slider.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/widgets/test_daterange_slider.py
@@ -0,0 +1,200 @@
+#-----------------------------------------------------------------------------
+# 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
+#-----------------------------------------------------------------------------
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+from datetime import date, datetime, timedelta
+from time import sleep
+
+# External imports
+from flaky import flaky
+
+# Bokeh imports
+from bokeh._testing.util.selenium import (
+    RECORD,
+    drag_range_slider,
+    get_slider_bar_color,
+    get_slider_title_text,
+    get_slider_title_value,
+)
+from bokeh.layouts import column
+from bokeh.models import (
+    Circle,
+    ColumnDataSource,
+    CustomAction,
+    CustomJS,
+    DateRangeSlider,
+    Plot,
+    Range1d,
+)
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.project",
+)
+
+start = date(2017, 8, 3)
+end = date(2017, 8, 10)
+value = (start + timedelta(days=1), end - timedelta(days=1))
+
+@pytest.mark.selenium
+class Test_DateRangeSlider(object):
+
+    def test_display(self, bokeh_model_page) -> None:
+        slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        children = el.find_elements_by_css_selector('div.bk-input-group > div')
+        assert len(children) == 2
+
+        assert page.has_no_console_errors()
+
+    def test_displays_title(self, bokeh_model_page) -> None:
+        slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
+
+        assert get_slider_title_text(page.driver, ".foo") == '04 Aug 2017 .. 09 Aug 2017'
+        assert get_slider_title_value(page.driver, ".foo") == '04 Aug 2017 .. 09 Aug 2017'
+
+        assert page.has_no_console_errors()
+
+
+    def test_title_updates(self, bokeh_model_page) -> None:
+        slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        assert get_slider_title_value(page.driver, ".foo") == "04 Aug 2017 .. 09 Aug 2017"
+
+        drag_range_slider(page.driver, ".foo", "lower", 50)
+        val = get_slider_title_value(page.driver, ".foo").split(" .. ")[0]
+        assert val > "04 Aug 2017"
+
+        drag_range_slider(page.driver, ".foo", "lower", -70)
+        val = get_slider_title_value(page.driver, ".foo").split(" .. ")[0]
+        assert val == "03 Aug 2017"
+
+        assert page.has_no_console_errors()
+
+    def test_displays_bar_color(self, bokeh_model_page) -> None:
+        slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300, bar_color="red")
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
+
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
+
+        assert page.has_no_console_errors()
+
+    # TODO (bev) test works locally but not in CI
+    @pytest.mark.skip
+    def test_js_on_change_executes(self, bokeh_model_page) -> None:
+        slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+        slider.js_on_change('value', CustomJS(code=RECORD("value", "cb_obj.value")))
+
+        page = bokeh_model_page(slider)
+
+        drag_range_slider(page.driver, ".foo", "lower", 50)
+
+        results = page.results
+        assert datetime.fromtimestamp(results['value'][0]/1000) > datetime(*date.fromisoformat("2017-08-04").timetuple()[:3])
+
+        drag_range_slider(page.driver, ".foo", "upper", -70)
+        assert datetime.fromtimestamp(results['value'][1]/1000) < datetime(*date.fromisoformat("2017-08-09").timetuple()[:3])
+
+        assert page.has_no_console_errors()
+
+
+    @flaky(max_runs=10)
+    def test_server_on_change_round_trip(self, bokeh_server_page) -> None:
+
+        def modify_doc(doc):
+            source = ColumnDataSource(dict(x=[1, 2], y=[1, 1], val=["a", "b"]))
+            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(CustomAction(callback=CustomJS(args=dict(s=source), code=RECORD("data", "s.data"))))
+            slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+            def cb(attr, old, new):
+                source.data['val'] = [slider.value_as_date[0].isoformat(), slider.value_as_date[1].isoformat()]
+
+            slider.on_change('value', cb)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_range_slider(page.driver, ".foo", "lower", 50)
+
+        page.click_custom_action()
+        results = page.results
+        new = results['data']['val']
+        assert new[0] > '2017-08-04'
+
+        drag_range_slider(page.driver, ".foo", "upper", -50)
+
+
+        page.click_custom_action()
+        results = page.results
+        new = results['data']['val']
+        assert new[1] < '2017-08-09'
+
+#         # XXX (bev) skip keypress part of test until it can be fixed
+#         # el = page.driver.find_element_by_css_selector('.foo')
+#         # handle = el.find_element_by_css_selector('.bk-noUi-handle-lower')
+#         # select_element_and_press_key(page.driver, handle, Keys.ARROW_RIGHT)
+
+#         # page.click_custom_action()
+#         # results = page.results
+#         # old, new = results['data']['val']
+#         # assert float(new[0]) >= 1
+
+#         # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+#         # assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_bar_color_updates(self, bokeh_server_page) -> None:
+
+        def modify_doc(doc):
+            plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+            slider = DateRangeSlider(start=start, end=end, value=value, css_classes=["foo"], width=300, bar_color="red")
+
+            def cb(attr, old, new):
+                slider.bar_color = "rgba(255, 255, 0, 1)"
+
+            slider.on_change('value', cb)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_range_slider(page.driver, ".foo", "lower", 150)
+
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
+
+        # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+        # assert page.has_no_console_errors()
diff --git a/tests/integration/widgets/test_dateslider.py b/tests/integration/widgets/test_dateslider.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/widgets/test_dateslider.py
@@ -0,0 +1,249 @@
+#-----------------------------------------------------------------------------
+# 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
+#-----------------------------------------------------------------------------
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# Standard library imports
+from datetime import date, datetime, timedelta
+from time import sleep
+
+# External imports
+from flaky import flaky
+
+# Bokeh imports
+from bokeh._testing.util.selenium import (
+    RECORD,
+    drag_slider,
+    get_slider_bar_color,
+    get_slider_title_text,
+    get_slider_title_value,
+)
+from bokeh.layouts import column
+from bokeh.models import (
+    Circle,
+    ColumnDataSource,
+    CustomAction,
+    CustomJS,
+    DateSlider,
+    Plot,
+    Range1d,
+)
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.project",
+)
+
+start = date(2017, 8, 3)
+end = date(2017, 8, 10)
+value = start + timedelta(days=1)
+
+@pytest.mark.selenium
+class Test_DateSlider(object):
+
+    def test_display(self, bokeh_model_page) -> None:
+        slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        children = el.find_elements_by_css_selector('div.bk-input-group > div')
+        assert len(children) == 2
+
+        assert page.has_no_console_errors()
+
+    def test_displays_title(self, bokeh_model_page) -> None:
+        slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
+
+        assert get_slider_title_text(page.driver, ".foo") == "04 Aug 2017"
+        assert get_slider_title_value(page.driver, ".foo") == "04 Aug 2017"
+
+        assert page.has_no_console_errors()
+
+    def test_title_updates(self, bokeh_model_page) -> None:
+        slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+        page = bokeh_model_page(slider)
+
+        assert get_slider_title_value(page.driver, ".foo") == "04 Aug 2017"
+
+        drag_slider(page.driver, ".foo", 50)
+        assert get_slider_title_value(page.driver, ".foo") > "04 Aug 2017"
+
+        drag_slider(page.driver, ".foo", -70)
+        assert get_slider_title_value(page.driver, ".foo") == "03 Aug 2017"
+
+        assert page.has_no_console_errors()
+
+    def test_displays_bar_color(self, bokeh_model_page) -> None:
+        slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300, bar_color="red")
+
+        page = bokeh_model_page(slider)
+
+        el = page.driver.find_element_by_css_selector('.foo')
+        assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
+
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
+
+        assert page.has_no_console_errors()
+
+    def test_js_on_change_executes(self, bokeh_model_page) -> None:
+        slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+        slider.js_on_change('value', CustomJS(code=RECORD("value", "cb_obj.value")))
+
+        page = bokeh_model_page(slider)
+
+        drag_slider(page.driver, ".foo", 150)
+
+        results = page.results
+        assert datetime.fromtimestamp(results['value']/1000) > datetime(*date.fromisoformat("2017-08-04").timetuple()[:3])
+
+        assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_on_change_round_trip(self, bokeh_server_page) -> None:
+
+        def modify_doc(doc):
+            source = ColumnDataSource(dict(x=[1, 2], y=[1, 1], val=["a", "b"]))
+            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(CustomAction(callback=CustomJS(args=dict(s=source), code=RECORD("data", "s.data"))))
+            slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300, step=24*3600*1000)
+
+            def cb(attr, old, new):
+                source.data['val'] = [slider.value_as_date.isoformat()]
+
+            slider.on_change('value', cb)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_slider(page.driver, ".foo", 50)
+
+        page.click_custom_action()
+        results = page.results
+        new = results['data']['val']
+        assert new[0] > '2017-08-04'
+
+        drag_slider(page.driver, ".foo", -70)
+
+        page.click_custom_action()
+        results = page.results
+        new = results['data']['val']
+        assert new[0] == '2017-08-03'
+
+        # XXX (bev) skip keypress part of test until it can be fixed
+        # el = page.driver.find_element_by_css_selector('.foo')
+        # handle = el.find_element_by_css_selector('.bk-noUi-handle')
+        # select_element_and_press_key(page.driver, handle, Keys.ARROW_RIGHT)
+
+        # page.click_custom_action()
+        # results = page.results
+        # old, new = results['data']['val']
+        # assert float(new) == 1
+
+        # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+        # assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_callback_value_vs_value_throttled(self, bokeh_server_page) -> None:
+        junk = dict(v=0, vt=0)
+
+        def modify_doc(doc):
+            plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+            slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+            def cbv(attr, old, new): junk['v'] += 1
+            def cbvt(attr, old, new): junk['vt'] += 1
+
+            slider.on_change('value', cbv)
+            slider.on_change('value_throttled', cbvt)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_slider(page.driver, ".foo", 30, release=False)
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        drag_slider(page.driver, ".foo", 30, release=False)
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        drag_slider(page.driver, ".foo", 30, release=False)
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        drag_slider(page.driver, ".foo", 30, release=True)
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        assert junk['v'] == 4
+        assert junk['vt'] == 1
+
+        # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+        # assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_bar_color_updates(self, bokeh_server_page) -> None:
+
+        def modify_doc(doc):
+            plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+            slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300, bar_color="red")
+
+            def cb(attr, old, new):
+                slider.bar_color = "rgba(255, 255, 0, 1)"
+
+            slider.on_change('value', cb)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_slider(page.driver, ".foo", 150)
+
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
+
+        # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+        # assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_title_updates(self, bokeh_server_page) -> None:
+
+        def modify_doc(doc):
+            plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+            slider = DateSlider(start=start, end=end, value=value, css_classes=["foo"], width=300)
+
+            def cb(attr, old, new):
+                slider.title = "baz"
+
+            slider.on_change('value', cb)
+            doc.add_root(column(slider, plot))
+
+        page = bokeh_server_page(modify_doc)
+
+        drag_slider(page.driver, ".foo", 150)
+
+        sleep(1) # noUiSlider does a transition that takes some time
+
+        assert get_slider_title_text(page.driver, ".foo") > "04 Aug 2017"
+
+        # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
+        # assert page.has_no_console_errors()
diff --git a/tests/integration/widgets/test_range_slider.py b/tests/integration/widgets/test_range_slider.py
--- a/tests/integration/widgets/test_range_slider.py
+++ b/tests/integration/widgets/test_range_slider.py
@@ -24,8 +24,11 @@
 # Bokeh imports
 from bokeh._testing.util.selenium import (
     RECORD,
-    ActionChains,
     Keys,
+    drag_range_slider,
+    get_slider_bar_color,
+    get_slider_title_text,
+    get_slider_title_value,
     select_element_and_press_key,
 )
 from bokeh.layouts import column
@@ -48,29 +51,6 @@
     "bokeh._testing.plugins.project",
 )
 
-def drag_slider(driver, css_class, location, distance):
-    el = driver.find_element_by_css_selector(css_class)
-    handle = el.find_element_by_css_selector('.bk-noUi-handle-' + location)
-    actions = ActionChains(driver)
-    actions.move_to_element(handle)
-    actions.click_and_hold()
-    actions.move_by_offset(distance, 0)
-    actions.release()
-    actions.perform()
-
-def get_title_text(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    return el.find_element_by_css_selector('div.bk-input-group > div.bk-slider-title').text
-
-def get_title_value(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    return el.find_element_by_css_selector('div.bk-input-group > div > span.bk-slider-value').text
-
-def get_bar_color(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    bar = el.find_element_by_css_selector('.bk-noUi-connect')
-    return bar.value_of_css_property('background-color')
-
 @pytest.mark.selenium
 class Test_RangeSlider(object):
 
@@ -93,8 +73,8 @@ def test_displays_title(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
 
-        assert get_title_text(page.driver, ".foo") == "bar: 1 .. 5"
-        assert get_title_value(page.driver, ".foo") == "1 .. 5"
+        assert get_slider_title_text(page.driver, ".foo") == "bar: 1 .. 5"
+        assert get_slider_title_value(page.driver, ".foo") == "1 .. 5"
 
         assert page.has_no_console_errors()
 
@@ -107,8 +87,8 @@ def test_displays_title_scientific(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
 
-        assert get_title_text(page.driver, ".foo") == "bar: 1.00e-6 .. 8.00e-6"
-        assert get_title_value(page.driver, ".foo") == "1.00e-6 .. 8.00e-6"
+        assert get_slider_title_text(page.driver, ".foo") == "bar: 1.00e-6 .. 8.00e-6"
+        assert get_slider_title_value(page.driver, ".foo") == "1.00e-6 .. 8.00e-6"
 
         assert page.has_no_console_errors()
 
@@ -117,20 +97,20 @@ def test_title_updates(self, bokeh_model_page) -> None:
 
         page = bokeh_model_page(slider)
 
-        assert get_title_value(page.driver, ".foo") == "1 .. 9"
+        assert get_slider_title_value(page.driver, ".foo") == "1 .. 9"
 
-        drag_slider(page.driver, ".foo", "lower", 50)
-        value = get_title_value(page.driver, ".foo").split()[0]
+        drag_range_slider(page.driver, ".foo", "lower", 50)
+        value = get_slider_title_value(page.driver, ".foo").split()[0]
         assert float(value) > 1
         assert float(value) == int(value) # integral step size
 
         # don't go past upper handle
-        drag_slider(page.driver, ".foo", "lower", 50)
-        value = get_title_value(page.driver, ".foo").split()[0]
+        drag_range_slider(page.driver, ".foo", "lower", 50)
+        value = get_slider_title_value(page.driver, ".foo").split()[0]
         assert float(value) > 2
 
-        drag_slider(page.driver, ".foo", "lower", -135)
-        value = get_title_value(page.driver, ".foo").split()[0]
+        drag_range_slider(page.driver, ".foo", "lower", -135)
+        value = get_slider_title_value(page.driver, ".foo").split()[0]
         assert float(value) == 0
 
         assert page.has_no_console_errors()
@@ -143,17 +123,17 @@ def test_keypress_event(self, bokeh_model_page) -> None:
         handle_lower = el.find_element_by_css_selector('.bk-noUi-handle-lower')
         handle_upper = el.find_element_by_css_selector('.bk-noUi-handle-upper')
         select_element_and_press_key(page.driver, handle_lower, Keys.ARROW_RIGHT, press_number=1)
-        assert get_title_value(page.driver, ".foo") == "2 .. 5"
+        assert get_slider_title_value(page.driver, ".foo") == "2 .. 5"
         select_element_and_press_key(page.driver, handle_lower, Keys.ARROW_LEFT, press_number=5)
-        assert get_title_value(page.driver, ".foo") == "0 .. 5"
+        assert get_slider_title_value(page.driver, ".foo") == "0 .. 5"
         select_element_and_press_key(page.driver, handle_lower, Keys.ARROW_RIGHT, press_number=11)
-        assert get_title_value(page.driver, ".foo") == "5 .. 5"
+        assert get_slider_title_value(page.driver, ".foo") == "5 .. 5"
         select_element_and_press_key(page.driver, handle_upper, Keys.ARROW_RIGHT, press_number=1)
-        assert get_title_value(page.driver, ".foo") == "5 .. 6"
+        assert get_slider_title_value(page.driver, ".foo") == "5 .. 6"
         select_element_and_press_key(page.driver, handle_upper, Keys.ARROW_LEFT, press_number=2)
-        assert get_title_value(page.driver, ".foo") == "5 .. 5"
+        assert get_slider_title_value(page.driver, ".foo") == "5 .. 5"
         select_element_and_press_key(page.driver, handle_upper, Keys.ARROW_RIGHT, press_number=6)
-        assert get_title_value(page.driver, ".foo") == "5 .. 10"
+        assert get_slider_title_value(page.driver, ".foo") == "5 .. 10"
 
         assert page.has_no_console_errors()
 
@@ -165,7 +145,7 @@ def test_displays_bar_color(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
 
-        assert get_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
 
         assert page.has_no_console_errors()
 
@@ -175,13 +155,13 @@ def test_js_on_change_executes(self, bokeh_model_page) -> None:
 
         page = bokeh_model_page(slider)
 
-        drag_slider(page.driver, ".foo", "lower", 150)
+        drag_range_slider(page.driver, ".foo", "lower", 150)
 
         results = page.results
         assert float(results['value'][0]) > 1
         assert float(results['value'][1]) == 5
 
-        drag_slider(page.driver, ".foo", "lower", 150)
+        drag_range_slider(page.driver, ".foo", "lower", 150)
 
         results = page.results
         assert float(results['value'][0]) > 1
@@ -208,7 +188,7 @@ def cb(attr, old, new):
 
         page = bokeh_server_page(modify_doc)
 
-        drag_slider(page.driver, ".foo", "lower", 50)
+        drag_range_slider(page.driver, ".foo", "lower", 50)
 
         page.click_custom_action()
         results = page.results
@@ -216,14 +196,14 @@ def cb(attr, old, new):
         assert float(old[0]) == 1
         assert float(new[0]) > 1
 
-        drag_slider(page.driver, ".foo", "lower", 50)
+        drag_range_slider(page.driver, ".foo", "lower", 50)
 
         page.click_custom_action()
         results = page.results
         old, new = results['data']['val']
         assert float(new[0]) > 2
 
-        drag_slider(page.driver, ".foo", "lower", -135)
+        drag_range_slider(page.driver, ".foo", "lower", -135)
 
         page.click_custom_action()
         results = page.results
@@ -258,11 +238,11 @@ def cb(attr, old, new):
 
         page = bokeh_server_page(modify_doc)
 
-        drag_slider(page.driver, ".foo", "lower", 150)
+        drag_range_slider(page.driver, ".foo", "lower", 150)
 
         sleep(1) # noUiSlider does a transition that takes some time
 
-        assert get_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
 
         # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
         # assert page.has_no_console_errors()
diff --git a/tests/integration/widgets/test_slider.py b/tests/integration/widgets/test_slider.py
--- a/tests/integration/widgets/test_slider.py
+++ b/tests/integration/widgets/test_slider.py
@@ -24,8 +24,11 @@
 # Bokeh imports
 from bokeh._testing.util.selenium import (
     RECORD,
-    ActionChains,
     Keys,
+    drag_slider,
+    get_slider_bar_color,
+    get_slider_title_text,
+    get_slider_title_value,
     select_element_and_press_key,
 )
 from bokeh.layouts import column
@@ -47,30 +50,6 @@
     "bokeh._testing.plugins.project",
 )
 
-def drag_slider(driver, css_class, distance, release=True):
-    el = driver.find_element_by_css_selector(css_class)
-    handle = el.find_element_by_css_selector('.bk-noUi-handle')
-    actions = ActionChains(driver)
-    actions.move_to_element(handle)
-    actions.click_and_hold()
-    actions.move_by_offset(distance, 0)
-    if release:
-        actions.release()
-    actions.perform()
-
-def get_title_text(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    return el.find_element_by_css_selector('div.bk-input-group > div.bk-slider-title').text
-
-def get_title_value(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    return el.find_element_by_css_selector('div.bk-input-group > div > span.bk-slider-value').text
-
-def get_bar_color(driver, css_class):
-    el = driver.find_element_by_css_selector(css_class)
-    bar = el.find_element_by_css_selector('.bk-noUi-connect')
-    return bar.value_of_css_property('background-color')
-
 @pytest.mark.selenium
 class Test_Slider(object):
 
@@ -93,8 +72,8 @@ def test_displays_title(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
 
-        assert get_title_text(page.driver, ".foo") == "bar: 1"
-        assert float(get_title_value(page.driver, ".foo")) == 1
+        assert get_slider_title_text(page.driver, ".foo") == "bar: 1"
+        assert float(get_slider_title_value(page.driver, ".foo")) == 1
 
         assert page.has_no_console_errors()
 
@@ -103,18 +82,18 @@ def test_title_updates(self, bokeh_model_page) -> None:
 
         page = bokeh_model_page(slider)
 
-        assert float(get_title_value(page.driver, ".foo")) == 1
+        assert float(get_slider_title_value(page.driver, ".foo")) == 1
 
         drag_slider(page.driver, ".foo", 50)
-        value = get_title_value(page.driver, ".foo")
+        value = get_slider_title_value(page.driver, ".foo")
         assert float(value) > 1
         assert float(value) == int(value) # integral step size
 
         drag_slider(page.driver, ".foo", 50)
-        assert float(get_title_value(page.driver, ".foo")) > 2
+        assert float(get_slider_title_value(page.driver, ".foo")) > 2
 
         drag_slider(page.driver, ".foo", -135)
-        assert float(get_title_value(page.driver, ".foo")) == 0
+        assert float(get_slider_title_value(page.driver, ".foo")) == 0
 
         assert page.has_no_console_errors()
 
@@ -125,11 +104,11 @@ def test_keypress_event(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         handle = el.find_element_by_css_selector('.bk-noUi-handle')
         select_element_and_press_key(page.driver, handle, Keys.ARROW_RIGHT, press_number=1)
-        assert float(get_title_value(page.driver, ".foo")) == 2
+        assert float(get_slider_title_value(page.driver, ".foo")) == 2
         select_element_and_press_key(page.driver, handle, Keys.ARROW_LEFT, press_number=3) # hit lower value and continue
-        assert float(get_title_value(page.driver, ".foo")) == 0
+        assert float(get_slider_title_value(page.driver, ".foo")) == 0
         select_element_and_press_key(page.driver, handle, Keys.ARROW_RIGHT, press_number=11) # hit higher value and continue
-        assert float(get_title_value(page.driver, ".foo")) == 10
+        assert float(get_slider_title_value(page.driver, ".foo")) == 10
         assert page.has_no_console_errors()
 
     def test_displays_bar_color(self, bokeh_model_page) -> None:
@@ -140,7 +119,7 @@ def test_displays_bar_color(self, bokeh_model_page) -> None:
         el = page.driver.find_element_by_css_selector('.foo')
         assert len(el.find_elements_by_css_selector('div.bk-input-group > div')) == 2
 
-        assert get_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 0, 0, 1)"
 
         assert page.has_no_console_errors()
 
@@ -264,7 +243,7 @@ def cb(attr, old, new):
 
         sleep(1) # noUiSlider does a transition that takes some time
 
-        assert get_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
+        assert get_slider_bar_color(page.driver, ".foo") == "rgba(255, 255, 0, 1)"
 
         # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
         # assert page.has_no_console_errors()
@@ -288,7 +267,7 @@ def cb(attr, old, new):
 
         sleep(1) # noUiSlider does a transition that takes some time
 
-        assert get_title_text(page.driver, ".foo") == "baz: 6"
+        assert get_slider_title_text(page.driver, ".foo") == "baz: 6"
 
         # XXX (bev) disabled until https://github.com/bokeh/bokeh/issues/7970 is resolved
         # assert page.has_no_console_errors()
diff --git a/tests/unit/bokeh/core/property/test_datetime.py b/tests/unit/bokeh/core/property/test_datetime.py
--- a/tests/unit/bokeh/core/property/test_datetime.py
+++ b/tests/unit/bokeh/core/property/test_datetime.py
@@ -75,6 +75,7 @@ class Test_Datetime(object):
 
     def test_valid(self, pd) -> None:
         prop = bcpd.Datetime()
+        assert prop.is_valid(None)
         assert prop.is_valid(0)
         assert prop.is_valid(1)
         assert prop.is_valid(0.0)
@@ -88,7 +89,6 @@ def test_valid(self, pd) -> None:
 
     def test_invalid(self) -> None:
         prop = bcpd.Datetime()
-        assert not prop.is_valid(None)
         assert not prop.is_valid("")
         assert not prop.is_valid(False)
         assert not prop.is_valid(True)
diff --git a/tests/unit/bokeh/models/widgets/test_slider.py b/tests/unit/bokeh/models/widgets/test_slider.py
--- a/tests/unit/bokeh/models/widgets/test_slider.py
+++ b/tests/unit/bokeh/models/widgets/test_slider.py
@@ -37,74 +37,111 @@
 # General API
 #-----------------------------------------------------------------------------
 
-def test_daterangeslider_value_as_datetime_when_set_as_datetime() -> None:
-    start = datetime(2017, 8, 9, 0, 0)
-    end = datetime(2017, 8, 10, 0, 0)
-    s = mws.DateRangeSlider(start=start, end=end, value=(start, end))
-    assert s.value_as_datetime == (start, end)
-
-def test_daterangeslider_value_as_datetime_when_set_as_timestamp() -> None:
-    start = datetime(2017, 8, 9, 0, 0)
-    end = datetime(2017, 8, 10, 0, 0)
-    s = mws.DateRangeSlider(start=start, end=end,
+class TestDateRangeSlider(object):
+
+    def test_value_as_datetime_when_set_as_datetime(self) -> None:
+        start = datetime(2017, 8, 9, 0, 0)
+        end = datetime(2017, 8, 10, 0, 0)
+        s = mws.DateRangeSlider(start=start, end=end, value=(start, end))
+        assert s.value_as_datetime == (start, end)
+
+    def test_value_as_datetime_when_set_as_timestamp(self) -> None:
+        start = datetime(2017, 8, 9, 0, 0)
+        end = datetime(2017, 8, 10, 0, 0)
+        s = mws.DateRangeSlider(start=start, end=end,
             # Bokeh serializes as ms since epoch, if they get set as numbers (e.g.)
             # by client side update, this is the units they will be
             value=(convert_datetime_type(start), convert_datetime_type(end)))
-    assert s.value_as_datetime == (start, end)
-
-def test_daterangeslider_value_as_datetime_when_set_mixed() -> None:
-    start = datetime(2017, 8, 9, 0, 0)
-    end = datetime(2017, 8, 10, 0, 0)
-    s = mws.DateRangeSlider(start=start, end=end,
-            value=(start, convert_datetime_type(end)))
-    assert s.value_as_datetime == (start, end)
-
-    s = mws.DateRangeSlider(start=start, end=end,
-            value=(convert_datetime_type(start), end))
-    assert s.value_as_datetime == (start, end)
-
-def test_daterangeslider_value_as_date_when_set_as_date() -> None:
-    start = date(2017, 8, 9)
-    end = date(2017, 8, 10)
-    s = mws.DateRangeSlider(start=start, end=end, value=(start, end))
-    assert s.value_as_date == (start, end)
-
-def test_daterangeslider_value_as_date_when_set_as_timestamp() -> None:
-    start = date(2017, 8, 9)
-    end = date(2017, 8, 10)
-    s = mws.DateRangeSlider(start=start, end=end,
+        assert s.value_as_datetime == (start, end)
+
+    def test_value_as_datetime_when_set_mixed(self) -> None:
+        start = datetime(2017, 8, 9, 0, 0)
+        end = datetime(2017, 8, 10, 0, 0)
+        s = mws.DateRangeSlider(start=start, end=end,
+                value=(start, convert_datetime_type(end)))
+        assert s.value_as_datetime == (start, end)
+
+        s = mws.DateRangeSlider(start=start, end=end,
+                value=(convert_datetime_type(start), end))
+        assert s.value_as_datetime == (start, end)
+
+    def test_value_as_date_when_set_as_date(self) -> None:
+        start = date(2017, 8, 9)
+        end = date(2017, 8, 10)
+        s = mws.DateRangeSlider(start=start, end=end, value=(start, end))
+        assert s.value_as_date == (start, end)
+
+    def test_value_as_date_when_set_as_timestamp(self) -> None:
+        start = date(2017, 8, 9)
+        end = date(2017, 8, 10)
+        s = mws.DateRangeSlider(start=start, end=end,
             # Bokeh serializes as ms since epoch, if they get set as numbers (e.g.)
             # by client side update, this is the units they will be
             value=(convert_date_to_datetime(start), convert_date_to_datetime(end)))
-    assert s.value_as_date == (start, end)
-
-def test_daterangeslider_value_as_date_when_set_mixed() -> None:
-    start = date(2017, 8, 9)
-    end = date(2017, 8, 10)
-    s = mws.DateRangeSlider(start=start, end=end,
-            value=(start, convert_date_to_datetime(end)))
-    assert s.value_as_date == (start, end)
-
-    s = mws.DateRangeSlider(start=start, end=end,
-            value=(convert_date_to_datetime(start), end))
-    assert s.value_as_date == (start, end)
-
-def test_rangeslider_equal_start_end_exception() -> None:
-    start = 0
-    end = 0
-    with pytest.raises(ValueError):
-        mws.RangeSlider(start=start, end=end)
-
-def test_rangeslider_equal_start_end_validation(caplog) -> None:
-    start = 0
-    end = 10
-    s = mws.RangeSlider(start=start, end=end)
-    #with caplog.at_level(logging.ERROR, logger='bokeh.core.validation.check'):
-    with caplog.at_level(logging.ERROR):
-        assert len(caplog.records) == 0
-        s.end = 0
-        check_integrity([s])
-        assert len(caplog.records) == 1
+        assert s.value_as_date == (start, end)
+
+    def test_value_as_date_when_set_mixed(self) -> None:
+        start = date(2017, 8, 9)
+        end = date(2017, 8, 10)
+        s = mws.DateRangeSlider(start=start, end=end,
+                value=(start, convert_date_to_datetime(end)))
+        assert s.value_as_date == (start, end)
+
+        s = mws.DateRangeSlider(start=start, end=end,
+                value=(convert_date_to_datetime(start), end))
+        assert s.value_as_date == (start, end)
+
+class TestDateSlider(object):
+
+    def test_value_as_datetime_when_set_as_datetime(self) -> None:
+        start = datetime(2017, 8, 9, 0, 0)
+        end = datetime(2017, 8, 10, 0, 0)
+        s = mws.DateSlider(start=start, end=end, value=start)
+        assert s.value_as_datetime == start
+
+    def test_value_as_datetime_when_set_as_timestamp(self) -> None:
+        start = datetime(2017, 8, 9, 0, 0)
+        end = datetime(2017, 8, 10, 0, 0)
+        s = mws.DateSlider(start=start, end=end,
+            # Bokeh serializes as ms since epoch, if they get set as numbers (e.g.)
+            # by client side update, this is the units they will be
+            value=convert_datetime_type(start))
+        assert s.value_as_datetime == start
+
+    def test_value_as_date_when_set_as_date(self) -> None:
+        start = date(2017, 8, 9)
+        end = date(2017, 8, 10)
+        s = mws.DateSlider(start=start, end=end, value=end)
+        assert s.value_as_date == end
+
+    def test_value_as_date_when_set_as_timestamp(self) -> None:
+        start = date(2017, 8, 9)
+        end = date(2017, 8, 10)
+        s = mws.DateSlider(start=start, end=end,
+            # Bokeh serializes as ms since epoch, if they get set as numbers (e.g.)
+            # by client side update, this is the units they will be
+            value=convert_date_to_datetime(end))
+        assert s.value_as_date == end
+
+class TestRangeSlider(object):
+
+    def test_rangeslider_equal_start_end_exception(self) -> None:
+        start = 0
+        end = 0
+        with pytest.raises(ValueError):
+            mws.RangeSlider(start=start, end=end)
+
+    def test_rangeslider_equal_start_end_validation(self, caplog) -> None:
+        start = 0
+        end = 10
+        s = mws.RangeSlider(start=start, end=end)
+        #with caplog.at_level(logging.ERROR, logger='bokeh.core.validation.check'):
+        with caplog.at_level(logging.ERROR):
+            assert len(caplog.records) == 0
+            s.end = 0
+            check_integrity([s])
+            assert len(caplog.records) == 1
+
 
 #-----------------------------------------------------------------------------
 # Dev API