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.