[MERGE] Backport of chrome headless support
For unknown [1] reason, phantomjs starts to segfault when running qunit tests. Be radical with simply not using phantomjs anymore. [1] Any guidance on tracking the root cause may help...
This commit is contained in:
@@ -437,7 +437,8 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
|
||||
console.log(log);
|
||||
});
|
||||
console.log(document.body.outerHTML);
|
||||
console.log("error " + error); // phantomJS wait for message starting by error
|
||||
console.error(error); // will be displayed as error info
|
||||
console.log("error"); // phantomJS wait for message starting by error to stop
|
||||
} else {
|
||||
console.log(_.str.sprintf("Tour %s succeeded", tour_name));
|
||||
console.log("ok"); // phantomJS wait for exact message "ok"
|
||||
|
||||
+430
-146
@@ -4,38 +4,49 @@ The module :mod:`odoo.tests.common` provides unittest test cases and a few
|
||||
helpers and classes to write tests.
|
||||
|
||||
"""
|
||||
import base64
|
||||
import collections
|
||||
import errno
|
||||
import glob
|
||||
import importlib
|
||||
import inspect
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import select
|
||||
import requests
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import werkzeug.urls
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, date
|
||||
from pprint import pformat
|
||||
from datetime import datetime, date
|
||||
|
||||
import requests
|
||||
from decorator import decorator
|
||||
from lxml import etree, html
|
||||
|
||||
from odoo.models import BaseModel
|
||||
from odoo.osv.expression import normalize_domain
|
||||
from odoo.tools import pycompat
|
||||
from odoo.tools.misc import find_in_path
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
try:
|
||||
from itertools import zip_longest as izip_longest
|
||||
except ImportError:
|
||||
from itertools import izip_longest
|
||||
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
# chrome headless tests will be skipped
|
||||
websocket = None
|
||||
|
||||
try:
|
||||
from xmlrpc import client as xmlrpclib
|
||||
except ImportError:
|
||||
@@ -326,10 +337,350 @@ class SavepointCase(SingleTransactionCase):
|
||||
super(SavepointCase, self).tearDown()
|
||||
|
||||
|
||||
class ChromeBrowser():
|
||||
""" Helper object to control a Chrome headless process. """
|
||||
|
||||
def __init__(self, logger):
|
||||
self._logger = logger
|
||||
if websocket is None:
|
||||
self._logger.warning("websocket-client module is not installed")
|
||||
raise unittest.SkipTest("websocket-client module is not installed")
|
||||
self.devtools_port = PORT + 2
|
||||
self.ws_url = '' # WebSocketUrl
|
||||
self.ws = None # websocket
|
||||
self.request_id = 0
|
||||
self.user_data_dir = tempfile.mkdtemp(suffix='_chrome_odoo')
|
||||
self.chrome_process = None
|
||||
self.screencast_frames = []
|
||||
self._chrome_start()
|
||||
self._find_websocket()
|
||||
self._logger.info('Websocket url found: %s', self.ws_url)
|
||||
self._open_websocket()
|
||||
self._logger.info('Enable chrome headless console log notification')
|
||||
self._websocket_send('Runtime.enable')
|
||||
self._logger.info('Chrome headless enable page notifications')
|
||||
self._websocket_send('Page.enable')
|
||||
self.sigxcpu_handler = None
|
||||
if os.name == 'posix':
|
||||
self.sigxcpu_handler = signal.getsignal(signal.SIGXCPU)
|
||||
signal.signal(signal.SIGXCPU, self.signal_handler)
|
||||
|
||||
def signal_handler(self, sig, frame):
|
||||
if sig == signal.SIGXCPU:
|
||||
_logger.info('CPU time limit reached, stopping Chrome and shutting down')
|
||||
self.stop()
|
||||
os._exit(0)
|
||||
|
||||
def stop(self):
|
||||
if self.chrome_process is not None:
|
||||
self._logger.info("Closing chrome headless with pid %s", self.chrome_process.pid)
|
||||
self._websocket_send('Browser.close')
|
||||
if self.chrome_process.poll() is None:
|
||||
self._logger.info("Terminating chrome headless with pid %s", self.chrome_process.pid)
|
||||
self.chrome_process.terminate()
|
||||
self.chrome_process.wait()
|
||||
if self.user_data_dir and os.path.isdir(self.user_data_dir) and self.user_data_dir != '/':
|
||||
self._logger.info('Removing chrome user profile "%s"', self.user_data_dir)
|
||||
shutil.rmtree(self.user_data_dir)
|
||||
# Restore previous signal handler
|
||||
if self.sigxcpu_handler and os.name == 'posix':
|
||||
signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
|
||||
|
||||
@property
|
||||
def executable(self):
|
||||
system = platform.system()
|
||||
if system == 'Linux':
|
||||
for bin_ in ['google-chrome', 'chromium', 'chromium-browser']:
|
||||
try:
|
||||
return find_in_path(bin_)
|
||||
except IOError:
|
||||
continue
|
||||
|
||||
elif system == 'Darwin':
|
||||
bins = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
]
|
||||
for bin_ in bins:
|
||||
if os.path.exists(bin_):
|
||||
return bin_
|
||||
|
||||
elif system == 'Windows':
|
||||
# TODO: handle windows platform: https://stackoverflow.com/a/40674915
|
||||
pass
|
||||
|
||||
raise unittest.SkipTest("Chrome executable not found")
|
||||
|
||||
def _chrome_start(self):
|
||||
if self.chrome_process is not None:
|
||||
return
|
||||
switches = {
|
||||
'--headless': '',
|
||||
'--enable-logging': 'stderr',
|
||||
'--no-default-browser-check': '',
|
||||
'--no-first-run': '',
|
||||
'--disable-extensions': '',
|
||||
'--user-data-dir': self.user_data_dir,
|
||||
'--disable-translate': '',
|
||||
'--window-size': '1366x768',
|
||||
'--remote-debugging-port': str(self.devtools_port)
|
||||
}
|
||||
cmd = [self.executable]
|
||||
cmd += ['%s=%s' % (k, v) if v else k for k, v in switches.items()]
|
||||
url = 'about:blank'
|
||||
cmd.append(url)
|
||||
self._logger.info('chrome_run executing %s', ' '.join(cmd))
|
||||
try:
|
||||
self.chrome_process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError:
|
||||
raise unittest.SkipTest("%s not found" % cmd[0])
|
||||
self._logger.info('Chrome pid: %s', self.chrome_process.pid)
|
||||
|
||||
def _find_websocket(self):
|
||||
version = self._json_command('version')
|
||||
self._logger.info('Browser version: %s', version['Browser'])
|
||||
try:
|
||||
infos = self._json_command('')[0] # Infos about the first tab
|
||||
except IndexError:
|
||||
self._logger.warning('No tab found in Chrome')
|
||||
self.stop()
|
||||
raise unittest.SkipTest('No tab found in Chrome')
|
||||
self.ws_url = infos['webSocketDebuggerUrl']
|
||||
self._logger.info('Chrome headless temporary user profile dir: %s', self.user_data_dir)
|
||||
|
||||
def _json_command(self, command, timeout=3):
|
||||
"""
|
||||
Inspect dev tools with get
|
||||
Available commands:
|
||||
'' : return list of tabs with their id
|
||||
list (or json/): list tabs
|
||||
new : open a new tab
|
||||
activate/ + an id: activate a tab
|
||||
close/ + and id: close a tab
|
||||
version : get chrome and dev tools version
|
||||
protocol : get the full protocol
|
||||
"""
|
||||
self._logger.info("Issuing json command %s", command)
|
||||
command = os.path.join('json', command).strip('/')
|
||||
while timeout > 0:
|
||||
try:
|
||||
url = werkzeug.urls.url_join('http://127.0.0.1:%s/' % self.devtools_port, command)
|
||||
self._logger.info('Url : %s', url)
|
||||
r = requests.get(url, timeout=3)
|
||||
if r.ok:
|
||||
return r.json()
|
||||
return {'status_code': r.status_code}
|
||||
except requests.ConnectionError:
|
||||
time.sleep(0.1)
|
||||
timeout -= 0.1
|
||||
except requests.exceptions.ReadTimeout:
|
||||
break
|
||||
self._logger.error('Could not connect to chrome debugger')
|
||||
raise unittest.SkipTest("Cannot connect to chrome headless")
|
||||
|
||||
def _open_websocket(self):
|
||||
self.ws = websocket.create_connection(self.ws_url)
|
||||
if self.ws.getstatus() != 101:
|
||||
raise unittest.SkipTest("Cannot connect to chrome dev tools")
|
||||
self.ws.settimeout(0.01)
|
||||
|
||||
def _websocket_send(self, method, params=None):
|
||||
"""
|
||||
send chrome devtools protocol commands through websocket
|
||||
"""
|
||||
sent_id = self.request_id
|
||||
payload = {
|
||||
'method': method,
|
||||
'id': sent_id,
|
||||
}
|
||||
if params:
|
||||
payload.update({'params': params})
|
||||
self.ws.send(json.dumps(payload))
|
||||
self.request_id += 1
|
||||
return sent_id
|
||||
|
||||
def _websocket_wait_id(self, awaited_id, timeout=10):
|
||||
"""
|
||||
blocking wait for a certain id in a response
|
||||
warning other messages are discarded
|
||||
"""
|
||||
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:
|
||||
return res
|
||||
self._logger.info('timeout exceeded while waiting for id : %d', awaited_id)
|
||||
return {}
|
||||
|
||||
def _websocket_wait_event(self, method, params=None, timeout=10):
|
||||
"""
|
||||
blocking wait for a particular event method and eventually a dict of params
|
||||
"""
|
||||
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:
|
||||
if params:
|
||||
if set(params).issubset(set(res.get('params', {}))):
|
||||
return res
|
||||
else:
|
||||
return res
|
||||
elif res:
|
||||
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
|
||||
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:
|
||||
f.write(decoded)
|
||||
self._logger.info('Screenshot in: %s', outfile)
|
||||
|
||||
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 !')
|
||||
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)
|
||||
|
||||
def start_screencast(self):
|
||||
self._websocket_send('Page.startScreencast', {'params': {'everyNthFrame': 5, 'maxWidth': 683, 'maxHeight': 384}})
|
||||
|
||||
def set_cookie(self, name, value, path, domain):
|
||||
params = {'name': name, 'value': value, 'path': path, 'domain': domain}
|
||||
self._websocket_send('Network.setCookie', params=params)
|
||||
|
||||
def _wait_ready(self, ready_code, timeout=60):
|
||||
self._logger.info('Evaluate ready code "%s"', ready_code)
|
||||
awaited_result = {'result': {'type': 'boolean', 'value': True}}
|
||||
ready_id = self._websocket_send('Runtime.evaluate', params={'expression': ready_code})
|
||||
last_bad_res = ''
|
||||
start_time = time.time()
|
||||
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:
|
||||
if res.get('result') == awaited_result:
|
||||
return True
|
||||
else:
|
||||
last_bad_res = res
|
||||
ready_id = self._websocket_send('Runtime.evaluate', params={'expression': ready_code})
|
||||
tdiff = time.time() - start_time
|
||||
if tdiff >= 2 and not has_exceeded:
|
||||
has_exceeded = True
|
||||
self._logger.warning('The ready code takes too much time : %s', tdiff)
|
||||
|
||||
self.take_screenshot(prefix='failed_ready')
|
||||
self._logger.info('Ready code last try result: %s', last_bad_res or res)
|
||||
return False
|
||||
|
||||
def _wait_code_ok(self, code, timeout):
|
||||
self._logger.info('Evaluate test code "%s"', code)
|
||||
code_id = self._websocket_send('Runtime.evaluate', params={'expression': code})
|
||||
start_time = time.time()
|
||||
logged_error = False
|
||||
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:
|
||||
self._logger.info('Code start result: %s', res)
|
||||
if res.get('result', {}).get('result').get('subtype', '') == 'error':
|
||||
self._logger.error("Running code returned an error")
|
||||
return False
|
||||
elif res and res.get('method') == 'Runtime.consoleAPICalled' and res.get('params', {}).get('type') in ('log', 'error'):
|
||||
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._logger.error(content)
|
||||
logged_error = True
|
||||
else:
|
||||
self._logger.info('console log: %s', content)
|
||||
for log in logs:
|
||||
if log.get('type', '') == 'string' and log.get('value', '').lower() == 'ok':
|
||||
# it is possible that some tests returns ok while an error was shown in logs.
|
||||
# since runbot should always be red in this case, better explicitly fail.
|
||||
if logged_error:
|
||||
return False
|
||||
return True
|
||||
elif log.get('type', '') == 'string' and log.get('value', '').lower().startswith('error'):
|
||||
self.take_screenshot()
|
||||
self._save_screencast()
|
||||
return False
|
||||
elif res and res.get('method') == 'Page.screencastFrame':
|
||||
self.screencast_frames.append(res.get('params'))
|
||||
elif res:
|
||||
self._logger.debug('chrome devtools protocol event: %s', res)
|
||||
self._logger.error('Script timeout exceeded : %s', (time.time() - start_time))
|
||||
self.take_screenshot()
|
||||
return False
|
||||
|
||||
def navigate_to(self, url, wait_stop=False):
|
||||
self._logger.info('Navigating to: "%s"', url)
|
||||
nav_id = self._websocket_send('Page.navigate', params={'url': url})
|
||||
nav_result = self._websocket_wait_id(nav_id)
|
||||
self._logger.info("Navigation result: %s", nav_result)
|
||||
frame_id = nav_result.get('result', {}).get('frameId', '')
|
||||
if wait_stop and frame_id:
|
||||
self._logger.info('Waiting for frame "%s" to stop loading', frame_id)
|
||||
self._websocket_wait_event('Page.frameStoppedLoading', params={'frameId': frame_id})
|
||||
|
||||
def clear(self):
|
||||
self._websocket_send('Page.stopScreencast')
|
||||
self.screencast_frames = []
|
||||
self._websocket_send('Page.stopLoading')
|
||||
self._logger.info('Deleting cookies and clearing local storage')
|
||||
dc_id = self._websocket_send('Network.clearBrowserCookies')
|
||||
self._websocket_wait_id(dc_id)
|
||||
cl_id = self._websocket_send('Runtime.evaluate', params={'expression': 'localStorage.clear()'})
|
||||
self._websocket_wait_id(cl_id)
|
||||
self.navigate_to('about:blank', wait_stop=True)
|
||||
|
||||
|
||||
class HttpCase(TransactionCase):
|
||||
""" Transactional HTTP TestCase with url_open and phantomjs helpers.
|
||||
""" Transactional HTTP TestCase with url_open and Chrome headless helpers.
|
||||
"""
|
||||
registry_test_mode = True
|
||||
browser = None
|
||||
|
||||
def __init__(self, methodName='runTest'):
|
||||
super(HttpCase, self).__init__(methodName)
|
||||
@@ -338,9 +689,25 @@ class HttpCase(TransactionCase):
|
||||
self.xmlrpc_common = xmlrpclib.ServerProxy(url_8 + 'common')
|
||||
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__))
|
||||
|
||||
@classmethod
|
||||
def start_browser(cls, logger):
|
||||
# start browser on demand
|
||||
if cls.browser is None:
|
||||
cls.browser = ChromeBrowser(logger)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if cls.browser:
|
||||
cls.browser.stop()
|
||||
cls.browser = None
|
||||
super(HttpCase, cls).tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super(HttpCase, self).setUp()
|
||||
|
||||
if self.registry_test_mode:
|
||||
self.registry.enter_test_mode(self.cr)
|
||||
self.addCleanup(self.registry.leave_test_mode)
|
||||
@@ -360,6 +727,27 @@ class HttpCase(TransactionCase):
|
||||
return self.opener.post(url, data=data, timeout=timeout)
|
||||
return self.opener.get(url, timeout=timeout)
|
||||
|
||||
def _wait_remaining_requests(self):
|
||||
t0 = int(time.time())
|
||||
for thread in threading.enumerate():
|
||||
if thread.name.startswith('odoo.service.http.request.'):
|
||||
join_retry_count = 10
|
||||
while thread.isAlive():
|
||||
# Need a busyloop here as thread.join() masks signals
|
||||
# and would prevent the forced shutdown.
|
||||
thread.join(0.05)
|
||||
join_retry_count -= 1
|
||||
if join_retry_count < 0:
|
||||
self._logger.warning("Stop waiting for thread %s handling request for url %s",
|
||||
thread.name, getattr(thread, 'url', '<UNKNOWN>'))
|
||||
break
|
||||
time.sleep(0.5)
|
||||
t1 = int(time.time())
|
||||
if t0 != t1:
|
||||
self._logger.info('remaining requests')
|
||||
odoo.tools.misc.dumpstacks()
|
||||
t0 = t1
|
||||
|
||||
def authenticate(self, user, password):
|
||||
# stay non-authenticated
|
||||
if user is None:
|
||||
@@ -383,130 +771,11 @@ class HttpCase(TransactionCase):
|
||||
session._fix_lang(session.context)
|
||||
|
||||
odoo.http.root.session_store.save(session)
|
||||
if self.browser:
|
||||
self._logger.info('Setting session cookie in browser')
|
||||
self.browser.set_cookie('session_id', self.session_id, '/', '127.0.0.1')
|
||||
|
||||
def phantom_poll(self, phantom, timeout):
|
||||
""" Phantomjs Test protocol.
|
||||
|
||||
Use console.log in phantomjs to output test results:
|
||||
|
||||
- for a success: console.log("ok")
|
||||
- for an error: console.log("error")
|
||||
|
||||
Other lines are relayed to the test log.
|
||||
|
||||
"""
|
||||
logger = _logger.getChild('phantomjs')
|
||||
t0 = datetime.now()
|
||||
td = timedelta(seconds=timeout)
|
||||
buf = bytearray()
|
||||
pid = phantom.stdout.fileno()
|
||||
while True:
|
||||
# timeout
|
||||
self.assertLess(datetime.now() - t0, td,
|
||||
"PhantomJS tests should take less than %s seconds" % timeout)
|
||||
|
||||
# read a byte
|
||||
try:
|
||||
ready, _, _ = select.select([pid], [], [], 0.5)
|
||||
except select.error as e:
|
||||
# In Python 2, select.error has no relation to IOError or
|
||||
# OSError, and no errno/strerror/filename, only a pair of
|
||||
# unnamed arguments (matching errno and strerror)
|
||||
err, _ = e.args
|
||||
if err == errno.EINTR:
|
||||
continue
|
||||
raise
|
||||
|
||||
if not ready:
|
||||
continue
|
||||
|
||||
s = os.read(pid, 4096)
|
||||
if not s:
|
||||
self.fail("Ran out of data to read")
|
||||
buf.extend(s)
|
||||
|
||||
# process lines
|
||||
while b'\n' in buf and (not buf.startswith(b'<phantomLog>') or b'</phantomLog>' in buf):
|
||||
|
||||
if buf.startswith(b'<phantomLog>'):
|
||||
line, buf = buf[12:].split(b'</phantomLog>\n', 1)
|
||||
else:
|
||||
line, buf = buf.split(b'\n', 1)
|
||||
line = line.decode('utf-8')
|
||||
|
||||
lline = line.lower()
|
||||
if lline.startswith(("error", "server application error")):
|
||||
try:
|
||||
# when errors occur the execution stack may be sent as a JSON
|
||||
prefix = lline.index('error') + 6
|
||||
self.fail(pformat(json.loads(line[prefix:])))
|
||||
except ValueError:
|
||||
self.fail(lline)
|
||||
elif lline.startswith("warning"):
|
||||
logger.warn(line)
|
||||
else:
|
||||
logger.info(line)
|
||||
|
||||
if line == "ok":
|
||||
return True
|
||||
|
||||
def phantom_run(self, cmd, timeout):
|
||||
_logger.info('phantom_run executing %s', ' '.join(cmd))
|
||||
|
||||
ls_glob = os.path.expanduser('~/.qws/share/data/Ofi Labs/PhantomJS/http_%s_%s.*' % (HOST, PORT))
|
||||
ls_glob2 = os.path.expanduser('~/.local/share/Ofi Labs/PhantomJS/http_%s_%s.*' % (HOST, PORT))
|
||||
for i in (glob.glob(ls_glob) + glob.glob(ls_glob2)):
|
||||
_logger.info('phantomjs unlink localstorage %s', i)
|
||||
os.unlink(i)
|
||||
try:
|
||||
phantom = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=None)
|
||||
except OSError:
|
||||
raise unittest.SkipTest("PhantomJS not found")
|
||||
try:
|
||||
result = self.phantom_poll(phantom, timeout)
|
||||
self.assertTrue(
|
||||
result,
|
||||
"PhantomJS test completed without reporting success; "
|
||||
"the log may contain errors or hints.")
|
||||
finally:
|
||||
# kill phantomjs if phantom.exit() wasn't called in the test
|
||||
if phantom.poll() is None:
|
||||
_logger.info("Terminating phantomjs")
|
||||
phantom.terminate()
|
||||
phantom.wait()
|
||||
else:
|
||||
# if we had to terminate phantomjs its return code is
|
||||
# always -15 so we don't care
|
||||
# check PhantomJS health
|
||||
from signal import SIGSEGV
|
||||
_logger.info("Phantom JS return code: %d" % phantom.returncode)
|
||||
if phantom.returncode == -SIGSEGV:
|
||||
_logger.error("Phantom JS has crashed (segmentation fault) during testing; log may not be relevant")
|
||||
|
||||
self._wait_remaining_requests()
|
||||
|
||||
def _wait_remaining_requests(self):
|
||||
t0 = int(time.time())
|
||||
for thread in threading.enumerate():
|
||||
if thread.name.startswith('odoo.service.http.request.'):
|
||||
join_retry_count = 10
|
||||
while thread.isAlive():
|
||||
# Need a busyloop here as thread.join() masks signals
|
||||
# and would prevent the forced shutdown.
|
||||
thread.join(0.05)
|
||||
join_retry_count -= 1
|
||||
if join_retry_count < 0:
|
||||
_logger.warning("Stop waiting for thread %s handling request for url %s",
|
||||
thread.name, thread.url)
|
||||
break
|
||||
time.sleep(0.5)
|
||||
t1 = int(time.time())
|
||||
if t0 != t1:
|
||||
_logger.info('remaining requests')
|
||||
odoo.tools.misc.dumpstacks()
|
||||
t0 = t1
|
||||
|
||||
def phantom_js(self, url_path, code, ready="window", login=None, timeout=60, **kw):
|
||||
def browser_js(self, url_path, code, ready='', login=None, timeout=60, **kw):
|
||||
""" Test js code running in the browser
|
||||
- optionnally log as 'login'
|
||||
- load page given by url_path
|
||||
@@ -521,23 +790,37 @@ class HttpCase(TransactionCase):
|
||||
|
||||
If neither are done before timeout test fails.
|
||||
"""
|
||||
options = {
|
||||
'port': PORT,
|
||||
'db': get_db_name(),
|
||||
'url_path': url_path,
|
||||
'code': code,
|
||||
'ready': ready,
|
||||
'timeout' : timeout,
|
||||
'session_id': self.session_id,
|
||||
}
|
||||
options.update(kw)
|
||||
# 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.authenticate(login, login)
|
||||
try:
|
||||
self.authenticate(login, login)
|
||||
url = "http://%s:%s%s" % (HOST, PORT, url_path or '/')
|
||||
self._logger.info('Open "%s" in browser', url)
|
||||
|
||||
phantomtest = os.path.join(os.path.dirname(__file__), 'phantomtest.js')
|
||||
cmd = ['phantomjs', phantomtest, json.dumps(options)]
|
||||
self.phantom_run(cmd, timeout)
|
||||
if odoo.tools.config['logfile']:
|
||||
self._logger.info('Starting screen cast')
|
||||
self.browser.start_screencast()
|
||||
self.browser.navigate_to(url)
|
||||
|
||||
# Needed because tests like test01.js (qunit tests) are passing a ready
|
||||
# code = ""
|
||||
ready = ready or "document.readyState === 'complete'"
|
||||
self.assertTrue(self.browser._wait_ready(ready), 'The ready "%s" code was always falsy' % ready)
|
||||
if code:
|
||||
message = 'The test code "%s" failed' % code
|
||||
else:
|
||||
message = "Some js test failed"
|
||||
self.assertTrue(self.browser._wait_code_ok(code, timeout), message)
|
||||
finally:
|
||||
# clear browser to make it stop sending requests, in case we call
|
||||
# the method several times in a test method
|
||||
self.browser.clear()
|
||||
self._wait_remaining_requests()
|
||||
|
||||
phantom_js = browser_js
|
||||
|
||||
def users(*logins):
|
||||
""" Decorate a method to execute it once for each given user. """
|
||||
@@ -1332,10 +1615,11 @@ class TagsSelector(object):
|
||||
tags = getattr(arg, 'test_tags', set())
|
||||
inter_no_test = self.exclude.intersection(tags)
|
||||
if inter_no_test:
|
||||
_logger.debug("Test '%s' not selected because of following tag(s): '%s'", arg, inter_no_test)
|
||||
_logger.debug("Test '%s' not selected because it is tagged with : %s (exclusions: %s)", arg, inter_no_test, self.exclude)
|
||||
return False
|
||||
inter_to_test = self.include.intersection(tags)
|
||||
if not inter_to_test:
|
||||
_logger.debug("Test '%s' not selected because it was not tagged with '%s'", arg, self.include)
|
||||
_logger.debug("Test '%s' not selected because it was not tagged with %s", arg, self.include)
|
||||
return False
|
||||
_logger.debug("Test '%s' selected: tagged with %s, exclusions: %s, inclusions: %s", arg, tags, self.exclude, self.include)
|
||||
return True
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
// Phantomjs odoo helper
|
||||
// jshint evil: true, loopfunc: true
|
||||
|
||||
var system = require('system');
|
||||
var HOST = '127.0.0.1';
|
||||
|
||||
function waitFor (condition, callback, timeout, timeoutMessageCallback) {
|
||||
timeout = timeout || 10000;
|
||||
var start = new Date();
|
||||
|
||||
(function waitLoop() {
|
||||
if(new Date() - start > timeout) {
|
||||
console.log('error', timeoutMessageCallback ? timeoutMessageCallback() : "Timeout after "+timeout+" ms");
|
||||
phantom.exit(1);
|
||||
} else if (condition()) {
|
||||
callback();
|
||||
} else {
|
||||
setTimeout(waitLoop, 250);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
function PhantomTest() {
|
||||
var self = this;
|
||||
this.options = JSON.parse(system.args[system.args.length-1]);
|
||||
this.inject = this.options.inject || [];
|
||||
this.timeout = this.options.timeout ? Math.round(parseFloat(this.options.timeout)*1000 - 5000) : 10000;
|
||||
this.origin = 'http://' + HOST;
|
||||
this.origin += this.options.port ? ':' + this.options.port : '';
|
||||
|
||||
// ----------------------------------------------------
|
||||
// configure phantom and page
|
||||
// ----------------------------------------------------
|
||||
phantom.addCookie({
|
||||
'domain': HOST,
|
||||
'name': 'session_id',
|
||||
'value': this.options.session_id,
|
||||
});
|
||||
this.page = require('webpage').create();
|
||||
this.page.viewportSize = { width: 1366, height: 768 };
|
||||
this.page.onError = function(message, trace) {
|
||||
var msg = [message];
|
||||
if (trace && trace.length) {
|
||||
msg.push.apply(msg, trace.map(function (frame) {
|
||||
var result = [' at ', frame.file, ':', frame.line];
|
||||
if (frame.function) {
|
||||
result.push(' (in ', frame.function, ')');
|
||||
}
|
||||
return result.join('');
|
||||
}));
|
||||
msg.push('(leaf frame on top)');
|
||||
}
|
||||
console.log('error', JSON.stringify(msg.join('\n')));
|
||||
phantom.exit(1);
|
||||
};
|
||||
this.page.onAlert = function(message) {
|
||||
console.log('error', message);
|
||||
phantom.exit(1);
|
||||
};
|
||||
this.page.onConsoleMessage = function(message) {
|
||||
console.log('<phantomLog>'+message+'</phantomLog>');
|
||||
};
|
||||
this.page.onLoadFinished = function(status) {
|
||||
console.log('PhantomJS: onLoadFinished. Status=', status);
|
||||
if (status === "success") {
|
||||
for (var k in self.inject) {
|
||||
var found = false;
|
||||
var v = self.inject[k];
|
||||
var need = v;
|
||||
var src = v;
|
||||
if (v[0]) {
|
||||
need = v[0];
|
||||
src = v[1];
|
||||
found = self.page.evaluate(function(code) {
|
||||
try {
|
||||
return !!eval(code);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}, need);
|
||||
}
|
||||
if(!found) {
|
||||
console.log('Injecting', src, 'needed for', need);
|
||||
if(!self.page.injectJs(src)) {
|
||||
console.log('error', "Cannot inject " + src);
|
||||
phantom.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(function () {
|
||||
self.page.evaluate(function () {
|
||||
var message = ("Timeout\nhref: " + window.location.href +
|
||||
"\nreferrer: " + document.referrer +
|
||||
"\n\n" + (document.body && document.body.innerHTML)).replace(/[^a-z0-9\s~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/gi, "*");
|
||||
console.log('<phantomLog>error ' + message + '</phantomLog>');
|
||||
});
|
||||
phantom.exit(1);
|
||||
}, self.timeout);
|
||||
|
||||
// ----------------------------------------------------
|
||||
// run test
|
||||
// ----------------------------------------------------
|
||||
this.run = function(url_path, code, ready) {
|
||||
var url = self.origin + url_path;
|
||||
code = code || "true";
|
||||
ready = ready || "true";
|
||||
self.page.open(url, function(status) {
|
||||
if (status !== 'success') {
|
||||
console.log('error', "failed to load " + url);
|
||||
phantom.exit(1);
|
||||
} else {
|
||||
console.log('loaded', url, status);
|
||||
// clear localstorage leftovers
|
||||
self.page.evaluate(function () { localStorage.clear() });
|
||||
// process ready
|
||||
waitFor(function() {
|
||||
console.log("PhantomTest.run: wait for condition:", ready);
|
||||
return self.page.evaluate(function (ready) {
|
||||
var r = false;
|
||||
try {
|
||||
console.log("page.evaluate eval expr:", ready);
|
||||
r = !!eval(ready);
|
||||
} catch(ex) {
|
||||
}
|
||||
console.log("page.evaluate eval result:", r);
|
||||
return r;
|
||||
}, ready);
|
||||
// run test
|
||||
}, function() {
|
||||
console.log("PhantomTest.run: condition statified, executing: " + code);
|
||||
self.page.evaluate(function (code) { return eval(code); }, code);
|
||||
console.log("PhantomTest.run: execution launched, waiting for console.log('ok')...");
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// js mode or jsfile mode
|
||||
if(system.args.length === 2) {
|
||||
pt = new PhantomTest();
|
||||
pt.run(pt.options.url_path, pt.options.code, pt.options.ready);
|
||||
}
|
||||
Reference in New Issue
Block a user