[IMP] tests: save screenshots and screencasts in temp

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) <xdo@odoo.com>
This commit is contained in:
Christophe Monniez
2019-07-19 12:14:36 +00:00
parent 1a49db046f
commit 0dc4a63e2d
4 changed files with 115 additions and 41 deletions
+14
View File
@@ -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 <feature,feature,...,feature>
* ``all``: all the features below are activated
+14
View File
@@ -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 <odoo-bin --screenshots>` and :option:`--screencasts <odoo-bin --screencasts>`
.. _qunit: http://qunitjs.com/
.. _qunit_config.js: https://github.com/odoo/odoo/blob/51ee0c3cb59810449a60dae0b086b49b1ed6f946/addons/web/static/tests/helpers/qunit_config.js#L49
+78 -40
View File
@@ -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))
+9 -1
View File
@@ -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'
]