diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst index de96e733959..6b1b755d0e2 100644 --- a/doc/reference/cmdline.rst +++ b/doc/reference/cmdline.rst @@ -117,6 +117,20 @@ Running the server select the tests to run by using tags. +.. option:: --screenshots + + Specify directory where to write screenshots when an HttpCase.browser_js test + fails. It defaults to :file:`/tmp/odoo_tests/{db_name}/screenshots` + +.. option:: --screencasts + + Enable screencasts and specify directory where to write screencasts files. + The ``ffmpeg`` utility needs to be installed to encode frames into a video + file. Otherwise frames will be kept instead of the video file. + + ``1``, ``t`` or ``true`` can be used to use the same directory as the above + mentioned ``--screenshots`` option. + .. option:: --dev * ``all``: all the features below are activated diff --git a/doc/reference/testing.rst b/doc/reference/testing.rst index c1d9410bc4a..4175c709719 100644 --- a/doc/reference/testing.rst +++ b/doc/reference/testing.rst @@ -513,6 +513,20 @@ business flow. It explains a sequence of steps that should be followed. The test runner will then create a phantom_js browser, point it to the proper url and simulate the click and inputs, according to the scenario. +Screenshots and screencasts during browser_js tests +--------------------------------------------------- + +When running tests that use HttpCase.browser_js from the command line, the Chrome +browser is used in headless mode. By default, if a test fails, a PNG screenshot is +taken at the moment of the failure and written in + +.. code-block:: console + + '/tmp/odoo_tests/{db_name}/screenshots/' + +Two new command line arguments were added since Odoo 13.0 to control this behavior: +:option:`--screenshots ` and :option:`--screencasts ` + .. _qunit: http://qunitjs.com/ .. _qunit_config.js: https://github.com/odoo/odoo/blob/51ee0c3cb59810449a60dae0b086b49b1ed6f946/addons/web/static/tests/helpers/qunit_config.js#L49 diff --git a/odoo/tests/common.py b/odoo/tests/common.py index b917aec5d2d..11d54f323a6 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -462,8 +462,9 @@ class SavepointCase(SingleTransactionCase): class ChromeBrowser(): """ Helper object to control a Chrome headless process. """ - def __init__(self, logger, window_size): + def __init__(self, logger, window_size, test_class): self._logger = logger + self.test_class = test_class if websocket is None: self._logger.warning("websocket-client module is not installed") raise unittest.SkipTest("websocket-client module is not installed") @@ -473,7 +474,19 @@ class ChromeBrowser(): self.request_id = 0 self.user_data_dir = tempfile.mkdtemp(suffix='_chrome_odoo') self.chrome_process = None + + otc = odoo.tools.config + self.screenshots_dir = os.path.join(otc['screenshots'], get_db_name(), 'screenshots') + self.screencasts_dir = None + if otc['screencasts']: + if otc['screencasts'] in ('1', 'true', 't'): + self.screencasts_dir = os.path.join(otc['screenshots'], get_db_name(), 'screencasts') + else: + self.screencasts_dir =os.path.join(otc['screencasts'], get_db_name(), 'screencasts') + self.screencast_frames = [] + os.makedirs(self.screenshots_dir, exist_ok=True) + self.window_size = window_size self._chrome_start() self._find_websocket() @@ -660,49 +673,60 @@ class ChromeBrowser(): self._logger.debug('chrome devtools protocol event: %s', res) self._logger.info('timeout exceeded while waiting for : %s', method) - def _get_shotname(self, prefix, ext): - """ return a unique filename for screenshot or screencast""" - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f') - base_file = os.path.splitext(odoo.tools.config['logfile'])[0] - name = '%s_%s_%s.%s' % (base_file, prefix, timestamp, ext) - return name - - def take_screenshot(self, prefix='failed'): - if not odoo.tools.config['logfile']: - self._logger.info('Screenshot disabled !') - return None + def take_screenshot(self, prefix='sc_', suffix=None): + if suffix is None: + suffix = '_%s' % self.test_class ss_id = self._websocket_send('Page.captureScreenshot') 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'))) - outfile = self._get_shotname(prefix, 'png') - with open(outfile, 'wb') as f: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + fname = '%s%s%s.png' % (prefix, timestamp,suffix) + full_path = os.path.join(self.screenshots_dir, fname) + with open(full_path, 'wb') as f: f.write(decoded) - self._logger.info('Screenshot in: %s', outfile) + self._logger.log(25, 'Screenshot in: %s', full_path) def _save_screencast(self, prefix='failed'): # could be encododed with something like that # ffmpeg -framerate 3 -i frame_%05d.png output.mp4 - if not odoo.tools.config['logfile']: - self._logger.info('Screencast disabled !') + if not self.screencast_frames: + self._logger.debug('No screencast frames to encode') return None - sdir = tempfile.mkdtemp(suffix='_chrome_odoo_screencast') - nb = 0 - for frame in self.screencast_frames: - outfile = os.path.join(sdir, 'frame_%05d.png' % nb) - with open(outfile, 'wb') as f: - f.write(base64.decodebytes(bytes(frame.get('data').encode('utf-8')))) - nb += 1 - framerate = int(nb / (self.screencast_frames[nb-1].get('metadata').get('timestamp') - self.screencast_frames[0].get('metadata').get('timestamp'))) - outfile = self._get_shotname(prefix, 'mp4') - r = subprocess.run(['ffmpeg', '-framerate', str(framerate), '-i', '%s/frame_%%05d.png' % sdir, outfile]) - shutil.rmtree(sdir) - if r.returncode == 0: - self._logger.info('Screencast in: %s', outfile) + + for f in self.screencast_frames: + with open(f['file_path'], 'rb') as b64_file: + frame = base64.decodebytes(b64_file.read()) + os.unlink(f['file_path']) + f['file_path'] = f['file_path'].replace('.b64', '.png') + with open(f['file_path'], 'wb') as png_file: + png_file.write(frame) + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + fname = '%s_screencast_%s.mp4' % (prefix, timestamp) + outfile = os.path.join(self.screencasts_dir, fname) + + try: + ffmpeg_path = find_in_path('ffmpeg') + except IOError: + ffmpeg_path = None + + if ffmpeg_path: + framerate = int(len(self.screencast_frames) / (self.screencast_frames[-1].get('timestamp') - self.screencast_frames[0].get('timestamp'))) + r = subprocess.run([ffmpeg_path, '-framerate', str(framerate), '-i', '%s/frame_%%05d.png' % self.screencasts_dir, outfile]) + self._logger.log(25, 'Screencast in: %s', outfile) + else: + outfile = outfile.strip('.mp4') + shutil.move(self.screencasts_frames_dir, outfile) + self._logger.log(25, 'Screencast frames in: %s', outfile) def start_screencast(self): - self._websocket_send('Page.startScreencast', {'params': {'everyNthFrame': 5, 'maxWidth': 683, 'maxHeight': 384}}) + if self.screencasts_dir: + os.makedirs(self.screencasts_dir, exist_ok=True) + self.screencasts_frames_dir = os.path.join(self.screencasts_dir, 'frames') + os.makedirs(self.screencasts_frames_dir, exist_ok=True) + self._websocket_send('Page.startScreencast', params={'maxWidth': 1024, 'maxHeight': 576}) def set_cookie(self, name, value, path, domain): params = {'name': name, 'value': value, 'path': path, 'domain': domain} @@ -733,7 +757,7 @@ class ChromeBrowser(): if tdiff >= 2 and not has_exceeded: has_exceeded = True - self.take_screenshot(prefix='failed_ready') + self.take_screenshot(prefix='sc_failed_ready_') self._logger.info('Ready code last try result: %s', last_bad_res or res) return False @@ -742,6 +766,7 @@ class ChromeBrowser(): code_id = self._websocket_send('Runtime.evaluate', params={'expression': code}) start_time = time.time() logged_error = False + nb_frame = 0 while time.time() - start_time < timeout: try: res = json.loads(self.ws.recv()) @@ -758,7 +783,7 @@ class ChromeBrowser(): self.take_screenshot() self._save_screencast() return False - elif res and res.get('method') in 'Runtime.consoleAPICalled' and res.get('params', {}).get('type') in ('log', 'error', 'trace'): + 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]) @@ -772,7 +797,17 @@ class ChromeBrowser(): if 'test successful' in content: return True elif res and res.get('method') == 'Page.screencastFrame': - self.screencast_frames.append(res.get('params')) + 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) + frame = res.get('params') + with open(outfile, 'w') as f: + f.write(frame.get('data')) + nb_frame += 1 + self.screencast_frames.append({ + 'file_path': outfile, + 'timestamp': frame.get('metadata').get('timestamp') + }) elif res: self._logger.debug('chrome devtools protocol event: %s', res) self._logger.error('Script timeout exceeded : %s', (time.time() - start_time)) @@ -791,6 +826,8 @@ class ChromeBrowser(): def clear(self): self._websocket_send('Page.stopScreencast') + if self.screencasts_dir and os.path.isdir(self.screencasts_frames_dir): + shutil.rmtree(self.screencasts_frames_dir) self.screencast_frames = [] self._websocket_send('Page.stopLoading') self._logger.info('Deleting cookies and clearing local storage') @@ -816,13 +853,13 @@ class HttpCase(TransactionCase): self.xmlrpc_db = xmlrpclib.ServerProxy(url_8 + 'db') self.xmlrpc_object = xmlrpclib.ServerProxy(url_8 + 'object') cls = type(self) - self._logger = logging.getLogger('%s.%s' % (cls.__module__, cls.__name__)) + cls._logger = logging.getLogger('%s.%s' % (cls.__module__, cls.__name__)) @classmethod - def start_browser(cls, logger): + def start_browser(cls): # start browser on demand if cls.browser is None: - cls.browser = ChromeBrowser(logger, cls.browser_size) + cls.browser = ChromeBrowser(cls._logger, cls.browser_size, cls.__name__) @classmethod def tearDownClass(cls): @@ -919,7 +956,8 @@ class HttpCase(TransactionCase): # increase timeout if coverage is running if any(f.filename.endswith('/coverage/execfile.py') for f in inspect.stack() if f.filename): timeout = timeout * 1.5 - self.start_browser(self._logger) + + self.start_browser() try: self.authenticate(login, login) @@ -929,8 +967,8 @@ class HttpCase(TransactionCase): url = "%s%s" % (base_url, url_path or '/') self._logger.info('Open "%s" in browser', url) - if odoo.tools.config['logfile']: - self._logger.info('Starting screen cast') + if self.browser.screencasts_dir: + self._logger.info('Starting screencast') self.browser.start_screencast() self.browser.navigate_to(url, wait_stop=not bool(ready)) diff --git a/odoo/tools/config.py b/odoo/tools/config.py index 583ab4ad267..ef409de56f9 100644 --- a/odoo/tools/config.py +++ b/odoo/tools/config.py @@ -11,6 +11,7 @@ import logging import optparse import os import sys +import tempfile import odoo from .. import release, conf, loglevels from . import appdirs @@ -169,6 +170,13 @@ class configmanager(object): examples: :TestClass.test_func,/test_module,external """) + group.add_option("--screencasts", dest="screencasts", action="store", my_default=None, + metavar='DIR', + help="Screencasts will go in DIR/{db_name}/screencasts. '1' can be used to force the same dir as for screenshots.") + temp_tests_dir = os.path.join(tempfile.gettempdir(), 'odoo_tests') + group.add_option("--screenshots", dest="screenshots", action="store", my_default=temp_tests_dir, + metavar='DIR', + help="Screenshots will go in DIR/{db_name}/screenshots. Defaults to %s." % temp_tests_dir) parser.add_option_group(group) # Logging Group @@ -413,7 +421,7 @@ class configmanager(object): 'db_port', 'db_template', 'logfile', 'pidfile', 'smtp_port', 'email_from', 'smtp_server', 'smtp_user', 'smtp_password', 'db_maxconn', 'import_partial', 'addons_path', - 'syslog', 'without_demo', + 'syslog', 'without_demo', 'screencasts', 'screenshots', 'dbfilter', 'log_level', 'log_db', 'log_db_level', 'geoip_database', 'dev_mode', 'shell_interface' ]