diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js
index 8f66a64b286703291130811bc2af5dad03e498fa..fc6a9c3d90054a41227b71130cb1ea082cbb4d22 100644
--- a/addons/bus/static/src/js/crosstab_bus.js
+++ b/addons/bus/static/src/js/crosstab_bus.js
@@ -348,7 +348,7 @@ var CrossTabBus = Longpolling.extend({
      */
     _onUnload: function () {
         // unload peer
-        var peers = this._callLocalStorage('getItem', 'peers', {});
+        var peers = this._callLocalStorage('getItem', 'peers') || {};
         delete peers[this._id];
         this._callLocalStorage('setItem', 'peers', peers);
 
diff --git a/addons/note/models/res_users.py b/addons/note/models/res_users.py
index d1bca02e65c7683e8a99d6880e476b6d9b62d4e7..0623a3d7a8a46ddcdc49397833d688d6e8bf6921 100644
--- a/addons/note/models/res_users.py
+++ b/addons/note/models/res_users.py
@@ -37,16 +37,14 @@ GROUP BY id"""
         self.browse(uids)._create_note_stages()
 
     def _create_note_stages(self):
-        data_found = False
         for num in range(4):
             stage = self.env.ref('note.note_stage_%02d' % (num,), raise_if_not_found=False)
-            data_found = True
-            if stage:
-                for user in self:
-                    stage.sudo().copy(default={'user_id': user.id})
-        if data_found:
+            if not stage:
+                break
             for user in self:
-                _logger.info('Note default columns created for user id %s', user.id)
+                stage.sudo().copy(default={'user_id': user.id})
+        else:
+            _logger.debug("Created note columns for %s", self)
 
     @api.model
     def systray_get_activities(self):
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 2264dec308770950621be6b26cb0250cf21ff9bc..4e6ea31f649d9993eab423559461d28509585f81 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -1023,7 +1023,7 @@ exports.PosModel = Backbone.Model.extend({
                     }
                     self.set('failed',error);
                 }
-                console.error('Failed to send orders:', orders);
+                console.warn('Failed to send orders:', orders);
                 self.gui.show_sync_error_popup();
                 throw reason;
             });
diff --git a/addons/test_website/static/tests/tours/reset_views.js b/addons/test_website/static/tests/tours/reset_views.js
index bc323eaec2f4f67df88064d7354cebda426ed8bd..0c7aa9517ad73cfe78fb68dc0c09cf9315443764 100644
--- a/addons/test_website/static/tests/tours/reset_views.js
+++ b/addons/test_website/static/tests/tours/reset_views.js
@@ -3,6 +3,15 @@ odoo.define('test_website.reset_views', function (require) {
 
 var tour = require("web_tour.tour");
 
+var BROKEN_STEP = {
+    // because saving a broken template opens a recovery page with no assets
+    // there's no way for the tour to resume on the new page, and thus no way
+    // to properly wait for the page to be saved & reloaded in order to fix the
+    // race condition of a tour ending on a side-effect (with the possible
+    // exception of somehow telling the harness / browser to do it)
+    trigger: 'body',
+    run: function () {}
+};
 tour.register('test_reset_page_view_complete_flow_part1', {
     test: true,
     url: '/test_page_view',
@@ -45,9 +54,8 @@ tour.register('test_reset_page_view_complete_flow_part1', {
             content: "save the html editor",
             extra_trigger: '.ace_content:contains("not.exist")',
             trigger: ".o_ace_view_editor button[data-action=save]",
-        }
-
-        // 3. Reset the broken view
+        },
+        BROKEN_STEP
     ]
 );
 
@@ -93,7 +101,8 @@ tour.register('test_reset_page_view_complete_flow_part2', {
         {
             content: "save the html editor",
             trigger: ".o_ace_view_editor button[data-action=save]",
-        }
+        },
+        BROKEN_STEP
     ]
 );
 
diff --git a/addons/web/static/src/js/boot.js b/addons/web/static/src/js/boot.js
index 3313188eff1b14ed40f617406b2ac25919b8fc2e..621307720b7c7cd1ad6ebba3fcd9fb7dc6aed398 100644
--- a/addons/web/static/src/js/boot.js
+++ b/addons/web/static/src/js/boot.js
@@ -261,8 +261,7 @@
                     jobs.splice(jobs.indexOf(job), 1);
                 } catch (e) {
                     job.error = e;
-                    console.error('Error while loading ' + job.name);
-                    console.error(e.stack);
+                    console.error('Error while loading ' + job.name + ': '+ e.stack);
                 }
                 if (!job.error) {
                     Promise.resolve(jobExec).then(
diff --git a/addons/web/static/src/js/core/ajax.js b/addons/web/static/src/js/core/ajax.js
index bc5250ab62015660df4f107a815c74d6f84ec702..cf5fe87b2ae1e80ea388fabb3dda5dd7252f8098 100644
--- a/addons/web/static/src/js/core/ajax.js
+++ b/addons/web/static/src/js/core/ajax.js
@@ -87,7 +87,8 @@ function _genericJsonRpc (fct_name, params, settings, fct) {
                     data: {
                         type: "xhr"+textStatus,
                         debug: error.responseText,
-                        objects: [error, errorThrown]
+                        objects: [error, errorThrown],
+                        arguments: [reason || textStatus]
                     },
                 };
                 reject({message: nerror, event: $.Event()});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 416e4c90e47ae27ab50c5db92f8607da9b512b3e..5c74a824fcb039af2f38f70b26b4aa0df37c0d22 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -2595,8 +2595,8 @@ QUnit.module('Views', {
 
         // Create event (on 20 december)
         var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)');
-        testUtils.triggerMouseEvent($cell, "mousedown");
-        testUtils.triggerMouseEvent($cell, "mouseup");
+        await testUtils.triggerMouseEvent($cell, "mousedown");
+        await testUtils.triggerMouseEvent($cell, "mouseup");
         await testUtils.nextTick();
         var $input = $('.modal-body input:first');
         await testUtils.fields.editInput($input, "An event");
diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js
index d117c24f9192aff667ef3679f580105ab584120c..cf95b3516c637e3aa59f9225d0f5938ca18c14ca 100644
--- a/addons/web_tour/static/src/js/tour_manager.js
+++ b/addons/web_tour/static/src/js/tour_manager.js
@@ -414,11 +414,14 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
             var action_helper = new RunningTourActionHelper(tip.widget);
             do_before_unload(self._consume_tip.bind(self, tip, tour_name));
 
+            var tour = self.tours[tour_name];
             if (typeof tip.run === "function") {
                 tip.run.call(tip.widget, action_helper);
             } else if (tip.run !== undefined) {
                 var m = tip.run.match(/^([a-zA-Z0-9_]+) *(?:\(? *(.+?) *\)?)?$/);
                 action_helper[m[1]](m[2]);
+            } else if (tour.current_step === tour.steps.length - 1) {
+                console.log('Tour %s: ignoring action (auto) of last step', tour_name);
             } else {
                 action_helper.auto();
             }
diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js
index 8c27fb5736ea135c6b416f8c5b1fdfa645af72b5..a851ea3d2a9c43c39c33aa670e67f41a4b9bac90 100644
--- a/addons/website/static/src/js/content/website_root.js
+++ b/addons/website/static/src/js/content/website_root.js
@@ -163,6 +163,7 @@ var WebsiteRoot = publicRootData.PublicRoot.extend({
             $data.parents("[data-publish]").attr("data-publish", +result ? 'on' : 'off');
         })
         .guardedCatch(function (err, data) {
+            data = data || {statusText: err.message.message};
             return new Dialog(self, {
                 title: data.data ? data.data.arguments[0] : "",
                 $content: $('<div/>', {
diff --git a/addons/website/static/tests/tours/dashboard_tour.js b/addons/website/static/tests/tours/dashboard_tour.js
index a8566d63a9719ad1576766fdc2adf9ad5916fb19..b02747c6caafbd695520252e25fb9aa440a55d0a 100644
--- a/addons/website/static/tests/tours/dashboard_tour.js
+++ b/addons/website/static/tests/tours/dashboard_tour.js
@@ -9,13 +9,14 @@ tour.register("backend_dashboard", {
 }, [tour.STEPS.SHOW_APPS_MENU_ITEM,
     {
     trigger: 'a[data-menu-xmlid="website.menu_website_configuration"]',
-    run: 'click',
 }, {
     trigger: '.dropdown-toggle[data-menu-xmlid="website.menu_dashboard"]',
-    run: 'click',
 }, {
     trigger: '.dropdown-item[data-menu-xmlid="website.menu_website_google_analytics"]',
-    content: 'Check if traceback',
-    run: 'click',
+}, {
+    // Visits section should always be present even when empty / not hooked to anything
+    trigger: 'h2:contains("Visits")',
+    content: "Check if dashboard loads",
+    run: function () {}
 }]);
 });
diff --git a/addons/website/static/tests/tours/reset_password.js b/addons/website/static/tests/tours/reset_password.js
index 9484a4c039fe5e79b20685ca95b94f139d3ab6e1..0ca5f602288981231bd736cfb699b0f0aa993db3 100644
--- a/addons/website/static/tests/tours/reset_password.js
+++ b/addons/website/static/tests/tours/reset_password.js
@@ -151,22 +151,7 @@ tour.register('website_reset_password', {
     },
     {
         content: "check logged in, and reset admin website",
-        trigger: '.oe_topbar_name:contains("Admin")',
-        run: function () {
-            return rpc.query({
-                model: 'res.partner',
-                method: 'name_search',
-                kwargs: {'name': 'Admin'},
-            }).then(function (res) {
-                return rpc.query({
-                    'model': 'res.partner',
-                    'method': 'write',
-                    'args': [[res[0][0]], {
-                        'website_id': false,
-                    }],
-                });
-            });
-        },
+        trigger: '.oe_topbar_name:contains("Admin")'
     },
 ]);
 });
diff --git a/odoo/addons/base/tests/test_orm.py b/odoo/addons/base/tests/test_orm.py
index ee169def65597af5ffa88831b7fe9be6cd9142a5..d4bf0ab8631fc9b7103d4915e77dd482dd3f4e8d 100644
--- a/odoo/addons/base/tests/test_orm.py
+++ b/odoo/addons/base/tests/test_orm.py
@@ -39,7 +39,7 @@ class TestORM(TransactionCase):
         with self.assertRaises(MissingError):
             p1.write({'name': 'foo'})
 
-    @mute_logger('odoo.models')
+    @mute_logger('odoo.models', 'odoo.addons.base.models.ir_rule')
     def test_access_filtered_records(self):
         """ Verify that accessing filtered records works as expected for non-admin user """
         p1 = self.env['res.partner'].create({'name': 'W'})
diff --git a/odoo/addons/test_access_rights/tests/test_ir_rules.py b/odoo/addons/test_access_rights/tests/test_ir_rules.py
index 114474760700a49ac61a69b94292f5d8abe5d7da..ce33a368434a84631ef24dcb649c938813f7dcab 100644
--- a/odoo/addons/test_access_rights/tests/test_ir_rules.py
+++ b/odoo/addons/test_access_rights/tests/test_ir_rules.py
@@ -3,6 +3,8 @@
 
 from odoo.exceptions import AccessError
 from odoo.tests.common import TransactionCase
+from odoo.tools import mute_logger
+
 
 class TestRules(TransactionCase):
     def setUp(self):
@@ -28,6 +30,7 @@ class TestRules(TransactionCase):
             'domain_force': "[('categ_id', 'in', user.env['test_access_right.obj_categ'].search([]).ids)]"
         })
 
+    @mute_logger('odoo.addons.base.models.ir_rule')
     def test_basic_access(self):
         env = self.env(user=self.browse_ref('base.public_user'))
 
@@ -44,6 +47,7 @@ class TestRules(TransactionCase):
         with self.assertRaises(AccessError):
             self.assertEqual(browse2.val, -1)
 
+    @mute_logger('odoo.addons.base.models.ir_rule')
     def test_group_rule(self):
         env = self.env(user=self.browse_ref('base.public_user'))
 
diff --git a/odoo/netsvc.py b/odoo/netsvc.py
index 2cf24a36a6163a6ae068d6d6da35535151f51fd7..ee4c60348b44a8f7fd18ad377a60ccf3a94c1043 100644
--- a/odoo/netsvc.py
+++ b/odoo/netsvc.py
@@ -200,7 +200,7 @@ def init_logger():
 
     logging_configurations = DEFAULT_LOG_CONFIGURATION + pseudo_config + logconfig
     for logconfig_item in logging_configurations:
-        loggername, level = logconfig_item.split(':')
+        loggername, level = logconfig_item.strip().split(':')
         level = getattr(logging, level, logging.INFO)
         logger = logging.getLogger(loggername)
         logger.setLevel(level)
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index 58f53195e422c03d8e190a8c6702fb4fb95b03de..49cf4cd281a2f760a04f36764ca683e3d8ed024e 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -718,6 +718,81 @@ class ChromeBrowser():
         self.request_id += 1
         return sent_id
 
+    def _get_message(self, raise_log_error=True):
+        """
+        :param bool raise_log_error:
+
+            by default, error logging messages reported by the browser are
+            converted to exception in order to fail the current test.
+
+            This is undersirable for *some* message loops, mostly when waiting
+            for a response to a command we've sent (wait_id): we do want to
+            properly handle exceptions and to forward the browser logs in order
+            to avoid losing information, but e.g. if the client generates two
+            console.error() we don't want the first call to take_screenshot to
+            trip up on the second console.error message and throw a second
+            exception. At the same time we don't want to *lose* the second
+            console.error as it might provide useful information.
+        """
+        try:
+            res = json.loads(self.ws.recv())
+        except websocket.WebSocketTimeoutException:
+            res = {}
+
+        if res.get('method') == 'Runtime.consoleAPICalled':
+            params = res['params']
+
+            # console formatting differs somewhat from Python's, if args[0] has
+            # format modifiers that many of args[1:] get formatted in, missing
+            # args are replaced by empty strings and extra args are concatenated
+            # (space-separated)
+            #
+            # current version modifies the args in place which could and should
+            # probably be improved
+            arg0, args = '', []
+            if params.get('args'):
+                arg0 = str(self._from_remoteobject(params['args'][0]))
+                args = params['args'][1:]
+            formatted = [re.sub(r'%[%sdfoOc]', self.console_formatter(args), arg0)]
+            # formatter consumes args it uses, leaves unformatted args untouched
+            formatted.extend(str(self._from_remoteobject(arg)) for arg in args)
+            message = ' '.join(formatted)
+            stack = ''.join(self._format_stack(params))
+            if stack:
+                message += '\n' + stack
+
+            log_type = params['type']
+            if raise_log_error and log_type == 'error':
+                self.take_screenshot()
+                self._save_screencast()
+                raise ChromeBrowserException(message)
+
+            self._logger.getChild('browser').log(
+                self._TO_LEVEL.get(log_type, logging.INFO),
+                "%s", message # might still have %<x> characters
+            )
+            res['success'] = 'test successful' in message
+
+        if res.get('method') == 'Runtime.exceptionThrown':
+            exception_details = res['params']['exceptionDetails']
+            descr = exception_details.get('exception', {}).get('description')
+            self.take_screenshot()
+            self._save_screencast()
+            raise ChromeBrowserException(descr or pprint.pformat(exception_details))
+
+        return res
+
+    _TO_LEVEL = {
+        'debug': logging.DEBUG,
+        'log': logging.INFO,
+        'info': logging.INFO,
+        'warning': logging.INFO, # logging.WARNING,
+        'error': logging.ERROR,
+        # TODO: what do with
+        # dir, dirxml, table, trace, clear, startGroup, startGroupCollapsed,
+        # endGroup, assert, profile, profileEnd, count, timeEnd
+    }
+
     def _websocket_wait_id(self, awaited_id, timeout=10):
         """
         blocking wait for a certain id in a response
@@ -725,11 +800,8 @@ class ChromeBrowser():
         """
         start_time = time.time()
         while time.time() - start_time < timeout:
-            try:
-                res = json.loads(self.ws.recv())
-            except websocket.WebSocketTimeoutException:
-                res = None
-            if res and res.get('id') == awaited_id:
+            res = self._get_message(raise_log_error=False)
+            if res.get('id') == awaited_id:
                 return res
         self._logger.info('timeout exceeded while waiting for id : %d', awaited_id)
         return {}
@@ -740,11 +812,8 @@ class ChromeBrowser():
         """
         start_time = time.time()
         while time.time() - start_time < timeout:
-            try:
-                res = json.loads(self.ws.recv())
-            except websocket.WebSocketTimeoutException:
-                res = None
-            if res and res.get('method', '') == method:
+            res = self._get_message()
+            if res.get('method', '') == method:
                 if params:
                     if set(params).issubset(set(res.get('params', {}))):
                         return res
@@ -761,9 +830,12 @@ class ChromeBrowser():
         self._logger.info('Asked for screenshot (id: %s)', ss_id)
         res = self._websocket_wait_id(ss_id)
         base_png = res.get('result', {}).get('data')
-        decoded = base64.decodebytes(bytes(base_png.encode('utf-8')))
-        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
-        fname = '%s%s%s.png' % (prefix, timestamp,suffix)
+        if not base_png:
+            self._logger.warning("Couldn't capture screenshot: expected image data, got %s", res)
+            return
+
+        decoded = base64.b64decode(base_png, validate=True)
+        fname = '{}{:%Y%m%d_%H%M%S_%f}{}.png'.format(prefix, datetime.now(), suffix)
         full_path = os.path.join(self.screenshots_dir, fname)
         with open(full_path, 'wb') as f:
             f.write(decoded)
@@ -829,11 +901,9 @@ class ChromeBrowser():
         tdiff = time.time() - start_time
         has_exceeded = False
         while tdiff < timeout:
-            try:
-                res = json.loads(self.ws.recv())
-            except websocket.WebSocketTimeoutException:
-                res = None
-            if res and res.get('id') == ready_id:
+            res = self._get_message()
+
+            if res.get('id') == ready_id:
                 if res.get('result') == awaited_result:
                     if has_exceeded:
                         self._logger.info('The ready code tooks too much time : %s', tdiff)
@@ -856,32 +926,15 @@ class ChromeBrowser():
         logged_error = False
         nb_frame = 0
         while time.time() - start_time < timeout:
-            try:
-                res = json.loads(self.ws.recv())
-            except websocket.WebSocketTimeoutException:
-                res = None
-            if res and res.get('id', -1) == code_id:
+            res = self._get_message()
+
+            if res.get('id', -1) == code_id:
                 self._logger.info('Code start result: %s', res)
                 if res.get('result', {}).get('result').get('subtype', '') == 'error':
                     raise ChromeBrowserException("Running code returned an error: %s" % res)
-            elif res and res.get('method') == 'Runtime.exceptionThrown':
-                exception_details = res.get('params', {}).get('exceptionDetails', {})
-                self.take_screenshot()
-                self._save_screencast()
-                raise ChromeBrowserException(exception_details)
-            elif res and res.get('method') == 'Runtime.consoleAPICalled' and res.get('params', {}).get('type') in ('log', 'error', 'trace'):
-                logs = res.get('params', {}).get('args')
-                log_type = res.get('params', {}).get('type')
-                content = " ".join([str(log.get('value', '')) for log in logs])
-                if log_type == 'error':
-                    self.take_screenshot()
-                    self._save_screencast()
-                    raise ChromeBrowserException(content)
-                else:
-                    self._logger.info('console log: %s', content)
-                    if 'test successful' in content:
-                        return True
-            elif res and res.get('method') == 'Page.screencastFrame':
+            elif res.get('success'):
+                return True
+            elif res.get('method') == 'Page.screencastFrame':
                 session_id = res.get('params').get('sessionId')
                 self._websocket_send('Page.screencastFrameAck', params={'sessionId': int(session_id)})
                 outfile = os.path.join(self.screencasts_frames_dir, 'frame_%05d.b64' % nb_frame)
@@ -925,6 +978,72 @@ class ChromeBrowser():
         self._websocket_wait_id(cl_id)
         self.navigate_to('about:blank', wait_stop=True)
 
+    def _from_remoteobject(self, arg):
+        """ attempts to make a CDT RemoteObject comprehensible
+        """
+        objtype = arg['type']
+        klass = arg.get('className', '')
+        subtype = arg.get('subtype')
+        if objtype == 'undefined':
+            # the undefined remoteobject is literally just {type: undefined}...
+            return 'undefined'
+        elif objtype != 'object' or subtype:
+            # value is the json representation for json object
+            # otherwise fallback on the description which is "a string
+            # representation of the object" e.g. the traceback for errors, the
+            # source for functions, ... finally fallback on the entire arg mess
+            return arg.get('value', arg.get('description', arg))
+
+        # all that's left is type=object, subtype=None aka custom or
+        # non-standard objects, print as TypeName(param=val, ...), sadly because
+        # of the way Odoo widgets are created they all appear as Class(...)
+        return '%s(%s)' % (
+            klass or objtype,
+            ', '.join(
+                '%s=%r' % (p['name'], p['value'])
+                for p in arg.get('preview', {}).get('properties', [])
+                if p.get('name') is not None
+                if p.get('value') is not None
+            )
+        )
+
+    LINE_PATTERN = '\tat %(functionName)s (%(url)s:%(lineNumber)d:%(columnNumber)d)\n'
+    def _format_stack(self, logrecord):
+        if logrecord['type'] not in ('error', 'trace', 'warning'):
+            return
+
+        trace = logrecord.get('stackTrace')
+        while trace:
+            for f in trace['callFrames']:
+                yield self.LINE_PATTERN % f
+            trace = trace.get('parent')
+
+    def console_formatter(self, args):
+        """ Formats similarly to the console API:
+
+        * if there are no args, don't format (return string as-is)
+        * %% -> %
+        * %c -> replace by styling directives (ignore for us)
+        * other known formatters -> replace by corresponding argument
+        * leftover known formatters (args exhausted) -> replace by empty string
+        * unknown formatters -> return as-is
+        """
+        if not args:
+            return lambda m: m[0]
+
+        def replacer(m):
+            fmt = m[0][1]
+            if fmt == '%':
+                return '%'
+            if fmt in 'sdfoOc':
+                if not args:
+                    return ''
+                repl = args.pop(0)
+                if fmt == 'c':
+                    return ''
+                return str(self._from_remoteobject(repl))
+            return m[0]
+        return replacer
 
 class HttpCase(TransactionCase):
     """ Transactional HTTP TestCase with url_open and Chrome headless helpers.