From 0dc4a63e2dec57f3187501129f2670a3f4463e79 Mon Sep 17 00:00:00 2001 From: Christophe Monniez Date: Mon, 1 Jul 2019 06:37:14 +0000 Subject: [PATCH] [IMP] tests: save screenshots and screencasts in temp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actually, when a browser_js test is running, screencasts and screenshots are saved only if a "--logfile" parameter is specified. This is problematic with the runbot because '--logfile' arg is not provided when running tests. Also, in most situations, developpers prefer to have logs on stdout. With this commit, two CLI args are added to specify screencasts and screenshots directories. If no screenshots dir is provided, it defaults to "{tmp_dir}/odoo_tests/{db_name}/screenshots". It means that screenshots are always enabled during browser_js tests. If screencasts arg is given, it enables screencasts and save them in "DIR/{db_name}/screencasts". If the screencasts parameter is "1", DIR will be the same as for screenshots. The ffmpeg tools is used to encode frames into a video. If the ffmpeg tool is not found on the system, the frames are kept as pngs files instead. closes odoo/odoo#34640 Signed-off-by: Xavier Dollé (xdo) --- doc/reference/cmdline.rst | 14 +++++ doc/reference/testing.rst | 14 +++++ odoo/tests/common.py | 118 +++++++++++++++++++++++++------------- odoo/tools/config.py | 10 +++- 4 files changed, 115 insertions(+), 41 deletions(-) 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' ]