[ADD] core: ability to run tours in a regular (headful) browser
Chrome's screencast / remote view (in the remote devtools) is apparently *designed* for touch emulation / simulation (https://crbug.com/1410433), which is not convenient when trying to debug mouse-bound issues in a tour (e.g. drag&drop problems). However we can "simply" run the thing in a normal browser, on a watch basis, and shut down the entire thing after each tour-call. This also obviates the need for complicated cleanup steps to try and isolate tours (as we can just delete the profile the browser created), although it is somewhat less efficient as we keep starting and stopping the browser. It does simplify the result of merging `clear` and `stop` (into stop). The switches setup does have a few foibles: - `--no-first-run` causes tours to not run at all on my machine, in a non-headless browser, so it's left just for headless - also moved a bunch of other flags which seem to be mostly for automated annoyances to headless only - left extensions for now, not entirely sure which is the right one, since we're running with fresh profiles I would assume there's no extensions anyway Part-of: odoo/odoo#111422
This commit is contained in:
+53
-85
@@ -838,7 +838,7 @@ class ChromeBrowser:
|
|||||||
""" Helper object to control a Chrome headless process. """
|
""" Helper object to control a Chrome headless process. """
|
||||||
remote_debugging_port = 0 # 9222, change it in a non-git-tracked file
|
remote_debugging_port = 0 # 9222, change it in a non-git-tracked file
|
||||||
|
|
||||||
def __init__(self, test_class):
|
def __init__(self, test_class, headless=True):
|
||||||
self._logger = test_class._logger
|
self._logger = test_class._logger
|
||||||
self.test_class = test_class
|
self.test_class = test_class
|
||||||
if websocket is None:
|
if websocket is None:
|
||||||
@@ -864,7 +864,7 @@ class ChromeBrowser:
|
|||||||
self.window_size = test_class.browser_size
|
self.window_size = test_class.browser_size
|
||||||
self.touch_enabled = test_class.touch_enabled
|
self.touch_enabled = test_class.touch_enabled
|
||||||
self.sigxcpu_handler = None
|
self.sigxcpu_handler = None
|
||||||
self._chrome_start()
|
self._chrome_start(headless=headless)
|
||||||
self._find_websocket()
|
self._find_websocket()
|
||||||
self._logger.info('Websocket url found: %s', self.ws_url)
|
self._logger.info('Websocket url found: %s', self.ws_url)
|
||||||
self._open_websocket()
|
self._open_websocket()
|
||||||
@@ -903,16 +903,32 @@ class ChromeBrowser:
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
if self.chrome_pid is not None:
|
self._websocket_send('Page.stopScreencast')
|
||||||
self._logger.info("Closing chrome headless with pid %s", self.chrome_pid)
|
if self.screencasts_dir and os.path.isdir(self.screencasts_frames_dir):
|
||||||
self._websocket_send('Browser.close')
|
shutil.rmtree(self.screencasts_frames_dir)
|
||||||
self._logger.info("Closing websocket connection")
|
|
||||||
self.ws.close()
|
self._websocket_request('Page.stopLoading')
|
||||||
self._logger.info("Terminating chrome headless with pid %s", self.chrome_pid)
|
self._websocket_request('Runtime.evaluate', params={'expression': """
|
||||||
os.kill(self.chrome_pid, signal.SIGTERM)
|
('serviceWorker' in navigator) &&
|
||||||
|
navigator.serviceWorker.getRegistrations().then(
|
||||||
|
registrations => Promise.all(registrations.map(r => r.unregister()))
|
||||||
|
)
|
||||||
|
""", 'awaitPromise': True})
|
||||||
|
# wait for the screenshot or whatever
|
||||||
|
wait(self._responses.values(), 10)
|
||||||
|
self._result.cancel()
|
||||||
|
|
||||||
|
self._logger.info("Closing chrome headless with pid %s", self.chrome_pid)
|
||||||
|
self._websocket_send('Browser.close')
|
||||||
|
self._logger.info("Closing websocket connection")
|
||||||
|
self.ws.close()
|
||||||
|
self._logger.info("Terminating chrome headless with pid %s", self.chrome_pid)
|
||||||
|
os.kill(self.chrome_pid, signal.SIGTERM)
|
||||||
|
|
||||||
if self.user_data_dir and os.path.isdir(self.user_data_dir) and self.user_data_dir != '/':
|
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)
|
self._logger.info('Removing chrome user profile "%s"', self.user_data_dir)
|
||||||
shutil.rmtree(self.user_data_dir, ignore_errors=True)
|
shutil.rmtree(self.user_data_dir, ignore_errors=True)
|
||||||
|
|
||||||
# Restore previous signal handler
|
# Restore previous signal handler
|
||||||
if self.sigxcpu_handler and os.name == 'posix':
|
if self.sigxcpu_handler and os.name == 'posix':
|
||||||
signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
|
signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
|
||||||
@@ -974,14 +990,12 @@ class ChromeBrowser:
|
|||||||
return proc.pid
|
return proc.pid
|
||||||
raise unittest.SkipTest(f'Failed to detect chrome devtools port after {BROWSER_WAIT :.1f}s.')
|
raise unittest.SkipTest(f'Failed to detect chrome devtools port after {BROWSER_WAIT :.1f}s.')
|
||||||
|
|
||||||
def _chrome_start(self):
|
def _chrome_start(self, headless=True):
|
||||||
if self.chrome_pid is not None:
|
if self.chrome_pid is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
switches = {
|
headless_switches = {
|
||||||
'--headless': '',
|
'--headless': '',
|
||||||
'--no-default-browser-check': '',
|
|
||||||
'--no-first-run': '',
|
|
||||||
'--disable-extensions': '',
|
'--disable-extensions': '',
|
||||||
'--disable-background-networking' : '',
|
'--disable-background-networking' : '',
|
||||||
'--disable-background-timer-throttling' : '',
|
'--disable-background-timer-throttling' : '',
|
||||||
@@ -990,23 +1004,28 @@ class ChromeBrowser:
|
|||||||
'--disable-breakpad': '',
|
'--disable-breakpad': '',
|
||||||
'--disable-client-side-phishing-detection': '',
|
'--disable-client-side-phishing-detection': '',
|
||||||
'--disable-crash-reporter': '',
|
'--disable-crash-reporter': '',
|
||||||
'--disable-default-apps': '',
|
|
||||||
'--disable-dev-shm-usage': '',
|
'--disable-dev-shm-usage': '',
|
||||||
'--disable-device-discovery-notifications': '',
|
|
||||||
'--disable-namespace-sandbox': '',
|
'--disable-namespace-sandbox': '',
|
||||||
'--user-data-dir': self.user_data_dir,
|
|
||||||
'--disable-translate': '',
|
'--disable-translate': '',
|
||||||
# required for tours that use Youtube autoplay conditions (namely website_slides' "course_tour")
|
'--no-first-run': '',
|
||||||
'--autoplay-policy': 'no-user-gesture-required',
|
|
||||||
'--window-size': self.window_size,
|
|
||||||
'--remote-debugging-address': HOST,
|
|
||||||
'--remote-debugging-port': str(self.remote_debugging_port),
|
|
||||||
'--no-sandbox': '',
|
'--no-sandbox': '',
|
||||||
'--disable-gpu': '',
|
'--disable-gpu': '',
|
||||||
'--remote-allow-origins': '*',
|
}
|
||||||
|
switches = {
|
||||||
|
# required for tours that use Youtube autoplay conditions (namely website_slides' "course_tour")
|
||||||
|
'--autoplay-policy': 'no-user-gesture-required',
|
||||||
|
'--disable-default-apps': '',
|
||||||
|
'--disable-device-discovery-notifications': '',
|
||||||
|
'--no-default-browser-check': '',
|
||||||
|
'--remote-debugging-address': HOST,
|
||||||
|
'--remote-debugging-port': str(self.remote_debugging_port),
|
||||||
|
'--user-data-dir': self.user_data_dir,
|
||||||
|
'--window-size': self.window_size,
|
||||||
# '--enable-precise-memory-info': '', # uncomment to debug memory leaks in qunit suite
|
# '--enable-precise-memory-info': '', # uncomment to debug memory leaks in qunit suite
|
||||||
# '--js-flags': '--expose-gc', # uncomment to debug memory leaks in qunit suite
|
# '--js-flags': '--expose-gc', # uncomment to debug memory leaks in qunit suite
|
||||||
}
|
}
|
||||||
|
if headless:
|
||||||
|
switches.update(headless_switches)
|
||||||
if self.touch_enabled:
|
if self.touch_enabled:
|
||||||
# enable Chrome's Touch mode, useful to detect touch capabilities using
|
# enable Chrome's Touch mode, useful to detect touch capabilities using
|
||||||
# "'ontouchstart' in window"
|
# "'ontouchstart' in window"
|
||||||
@@ -1437,34 +1456,6 @@ which leads to stray network requests and inconsistencies."""))
|
|||||||
self._logger.info('Waiting for frame %r to stop loading', frame_id)
|
self._logger.info('Waiting for frame %r to stop loading', frame_id)
|
||||||
e.wait(10)
|
e.wait(10)
|
||||||
|
|
||||||
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_request('Page.stopLoading')
|
|
||||||
self._websocket_request('Runtime.evaluate', params={'expression': """
|
|
||||||
('serviceWorker' in navigator) &&
|
|
||||||
navigator.serviceWorker.getRegistrations().then(
|
|
||||||
registrations => Promise.all(registrations.map(r => r.unregister()))
|
|
||||||
)
|
|
||||||
""", 'awaitPromise': True})
|
|
||||||
# wait for the screenshot or whatever
|
|
||||||
wait(self._responses.values(), 10)
|
|
||||||
self._logger.info('Deleting cookies and clearing local storage')
|
|
||||||
self._websocket_request('Network.clearBrowserCache')
|
|
||||||
self._websocket_request('Network.clearBrowserCookies')
|
|
||||||
self._websocket_request('Runtime.evaluate', params={'expression': 'try {localStorage.clear(); sessionStorage.clear();} catch(e) {}'})
|
|
||||||
self.navigate_to('about:blank', wait_stop=True)
|
|
||||||
# hopefully after navigating to about:blank there's no event left
|
|
||||||
self._frames.clear()
|
|
||||||
# wait for the clearing requests to finish in case the browser is re-used
|
|
||||||
wait(self._responses.values(), 10)
|
|
||||||
self._responses.clear()
|
|
||||||
self._result.cancel()
|
|
||||||
self._result = Future()
|
|
||||||
self.had_failure = False
|
|
||||||
|
|
||||||
def _from_remoteobject(self, arg):
|
def _from_remoteobject(self, arg):
|
||||||
""" attempts to make a CDT RemoteObject comprehensible
|
""" attempts to make a CDT RemoteObject comprehensible
|
||||||
"""
|
"""
|
||||||
@@ -1604,19 +1595,6 @@ class HttpCase(TransactionCase):
|
|||||||
# setup an url opener helper
|
# setup an url opener helper
|
||||||
self.opener = Opener(self.cr)
|
self.opener = Opener(self.cr)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def start_browser(cls):
|
|
||||||
# start browser on demand
|
|
||||||
if cls.browser is None:
|
|
||||||
cls.browser = ChromeBrowser(cls)
|
|
||||||
cls.addClassCleanup(cls.terminate_browser)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def terminate_browser(cls):
|
|
||||||
if cls.browser:
|
|
||||||
cls.browser.stop()
|
|
||||||
cls.browser = None
|
|
||||||
|
|
||||||
def url_open(self, url, data=None, files=None, timeout=12, headers=None, allow_redirects=True, head=False):
|
def url_open(self, url, data=None, files=None, timeout=12, headers=None, allow_redirects=True, head=False):
|
||||||
if url.startswith('/'):
|
if url.startswith('/'):
|
||||||
url = self.base_url() + url
|
url = self.base_url() + url
|
||||||
@@ -1651,7 +1629,7 @@ class HttpCase(TransactionCase):
|
|||||||
self.session.logout(keep_db=keep_db)
|
self.session.logout(keep_db=keep_db)
|
||||||
odoo.http.root.session_store.save(self.session)
|
odoo.http.root.session_store.save(self.session)
|
||||||
|
|
||||||
def authenticate(self, user, password):
|
def authenticate(self, user, password, browser: ChromeBrowser = None):
|
||||||
if getattr(self, 'session', None):
|
if getattr(self, 'session', None):
|
||||||
odoo.http.root.session_store.delete(self.session)
|
odoo.http.root.session_store.delete(self.session)
|
||||||
|
|
||||||
@@ -1688,9 +1666,9 @@ class HttpCase(TransactionCase):
|
|||||||
# completely) or clear-ing session.cookies.
|
# completely) or clear-ing session.cookies.
|
||||||
self.opener = Opener(self.cr)
|
self.opener = Opener(self.cr)
|
||||||
self.opener.cookies['session_id'] = session.sid
|
self.opener.cookies['session_id'] = session.sid
|
||||||
if self.browser:
|
if browser:
|
||||||
self._logger.info('Setting session cookie in browser')
|
self._logger.info('Setting session cookie in browser')
|
||||||
self.browser.set_cookie('session_id', session.sid, '/', HOST)
|
browser.set_cookie('session_id', session.sid, '/', HOST)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -1700,7 +1678,6 @@ class HttpCase(TransactionCase):
|
|||||||
- load page given by url_path
|
- load page given by url_path
|
||||||
- wait for ready object to be available
|
- wait for ready object to be available
|
||||||
- eval(code) inside the page
|
- eval(code) inside the page
|
||||||
- open another chrome window to watch code execution if watch is True
|
|
||||||
|
|
||||||
To signal success test do: console.log('test successful')
|
To signal success test do: console.log('test successful')
|
||||||
To signal test failure raise an exception or call console.error with a message.
|
To signal test failure raise an exception or call console.error with a message.
|
||||||
@@ -1714,15 +1691,9 @@ class HttpCase(TransactionCase):
|
|||||||
if any(f.filename.endswith('/coverage/execfile.py') for f in inspect.stack() if f.filename):
|
if any(f.filename.endswith('/coverage/execfile.py') for f in inspect.stack() if f.filename):
|
||||||
timeout = timeout * 1.5
|
timeout = timeout * 1.5
|
||||||
|
|
||||||
self.start_browser()
|
browser = ChromeBrowser(type(self), headless=not watch)
|
||||||
if watch and self.browser.dev_tools_frontend_url:
|
|
||||||
_logger.warning('watch mode is only suitable for local testing - increasing tour timeout to 3600')
|
|
||||||
timeout = max(timeout*10, 3600)
|
|
||||||
debug_front_end = f'http://127.0.0.1:{self.browser.devtools_port}{self.browser.dev_tools_frontend_url}'
|
|
||||||
self.browser._chrome_without_limit([self.browser.executable, debug_front_end])
|
|
||||||
time.sleep(3)
|
|
||||||
try:
|
try:
|
||||||
self.authenticate(login, login)
|
self.authenticate(login, login, browser=browser)
|
||||||
# Flush and clear the current transaction. This is useful in case
|
# Flush and clear the current transaction. This is useful in case
|
||||||
# we make requests to the server, as these requests are made with
|
# we make requests to the server, as these requests are made with
|
||||||
# test cursors, which uses different caches than this transaction.
|
# test cursors, which uses different caches than this transaction.
|
||||||
@@ -1736,23 +1707,23 @@ class HttpCase(TransactionCase):
|
|||||||
url = parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
|
url = parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
|
||||||
self._logger.info('Open "%s" in browser', url)
|
self._logger.info('Open "%s" in browser', url)
|
||||||
|
|
||||||
if self.browser.screencasts_dir:
|
if browser.screencasts_dir:
|
||||||
self._logger.info('Starting screencast')
|
self._logger.info('Starting screencast')
|
||||||
self.browser.start_screencast()
|
browser.start_screencast()
|
||||||
if cookies:
|
if cookies:
|
||||||
for name, value in cookies.items():
|
for name, value in cookies.items():
|
||||||
self.browser.set_cookie(name, value, '/', HOST)
|
browser.set_cookie(name, value, '/', HOST)
|
||||||
|
|
||||||
self.browser.navigate_to(url, wait_stop=not bool(ready))
|
browser.navigate_to(url, wait_stop=not bool(ready))
|
||||||
|
|
||||||
# Needed because tests like test01.js (qunit tests) are passing a ready
|
# Needed because tests like test01.js (qunit tests) are passing a ready
|
||||||
# code = ""
|
# code = ""
|
||||||
ready = ready or "document.readyState === 'complete'"
|
ready = ready or "document.readyState === 'complete'"
|
||||||
self.assertTrue(self.browser._wait_ready(ready), 'The ready "%s" code was always falsy' % ready)
|
self.assertTrue(browser._wait_ready(ready), 'The ready "%s" code was always falsy' % ready)
|
||||||
|
|
||||||
error = False
|
error = False
|
||||||
try:
|
try:
|
||||||
self.browser._wait_code_ok(code, timeout, error_checker=error_checker)
|
browser._wait_code_ok(code, timeout, error_checker=error_checker)
|
||||||
except ChromeBrowserException as chrome_browser_exception:
|
except ChromeBrowserException as chrome_browser_exception:
|
||||||
error = chrome_browser_exception
|
error = chrome_browser_exception
|
||||||
if error: # dont keep initial traceback, keep that outside of except
|
if error: # dont keep initial traceback, keep that outside of except
|
||||||
@@ -1763,10 +1734,7 @@ class HttpCase(TransactionCase):
|
|||||||
self.fail('%s\n\n%s' % (message, error))
|
self.fail('%s\n\n%s' % (message, error))
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# clear browser to make it stop sending requests, in case we call
|
browser.stop()
|
||||||
# the method several times in a test method
|
|
||||||
self.browser.delete_cookie('session_id', domain=HOST)
|
|
||||||
self.browser.clear()
|
|
||||||
self._wait_remaining_requests()
|
self._wait_remaining_requests()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user