[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:
Xavier Morel
2023-05-19 10:18:47 +02:00
parent 78991b1c80
commit 2b0d9fa6a9
+53 -85
View File
@@ -838,7 +838,7 @@ class ChromeBrowser:
""" Helper object to control a Chrome headless process. """
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.test_class = test_class
if websocket is None:
@@ -864,7 +864,7 @@ class ChromeBrowser:
self.window_size = test_class.browser_size
self.touch_enabled = test_class.touch_enabled
self.sigxcpu_handler = None
self._chrome_start()
self._chrome_start(headless=headless)
self._find_websocket()
self._logger.info('Websocket url found: %s', self.ws_url)
self._open_websocket()
@@ -903,16 +903,32 @@ class ChromeBrowser:
os._exit(0)
def stop(self):
if self.chrome_pid is not None:
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)
self._websocket_send('Page.stopScreencast')
if self.screencasts_dir and os.path.isdir(self.screencasts_frames_dir):
shutil.rmtree(self.screencasts_frames_dir)
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._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 != '/':
self._logger.info('Removing chrome user profile "%s"', self.user_data_dir)
shutil.rmtree(self.user_data_dir, ignore_errors=True)
# Restore previous signal handler
if self.sigxcpu_handler and os.name == 'posix':
signal.signal(signal.SIGXCPU, self.sigxcpu_handler)
@@ -974,14 +990,12 @@ class ChromeBrowser:
return proc.pid
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:
return
switches = {
headless_switches = {
'--headless': '',
'--no-default-browser-check': '',
'--no-first-run': '',
'--disable-extensions': '',
'--disable-background-networking' : '',
'--disable-background-timer-throttling' : '',
@@ -990,23 +1004,28 @@ class ChromeBrowser:
'--disable-breakpad': '',
'--disable-client-side-phishing-detection': '',
'--disable-crash-reporter': '',
'--disable-default-apps': '',
'--disable-dev-shm-usage': '',
'--disable-device-discovery-notifications': '',
'--disable-namespace-sandbox': '',
'--user-data-dir': self.user_data_dir,
'--disable-translate': '',
# required for tours that use Youtube autoplay conditions (namely website_slides' "course_tour")
'--autoplay-policy': 'no-user-gesture-required',
'--window-size': self.window_size,
'--remote-debugging-address': HOST,
'--remote-debugging-port': str(self.remote_debugging_port),
'--no-first-run': '',
'--no-sandbox': '',
'--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
# '--js-flags': '--expose-gc', # uncomment to debug memory leaks in qunit suite
}
if headless:
switches.update(headless_switches)
if self.touch_enabled:
# enable Chrome's Touch mode, useful to detect touch capabilities using
# "'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)
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):
""" attempts to make a CDT RemoteObject comprehensible
"""
@@ -1604,19 +1595,6 @@ class HttpCase(TransactionCase):
# setup an url opener helper
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):
if url.startswith('/'):
url = self.base_url() + url
@@ -1651,7 +1629,7 @@ class HttpCase(TransactionCase):
self.session.logout(keep_db=keep_db)
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):
odoo.http.root.session_store.delete(self.session)
@@ -1688,9 +1666,9 @@ class HttpCase(TransactionCase):
# completely) or clear-ing session.cookies.
self.opener = Opener(self.cr)
self.opener.cookies['session_id'] = session.sid
if self.browser:
if 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
@@ -1700,7 +1678,6 @@ class HttpCase(TransactionCase):
- load page given by url_path
- wait for ready object to be available
- 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 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):
timeout = timeout * 1.5
self.start_browser()
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)
browser = ChromeBrowser(type(self), headless=not watch)
try:
self.authenticate(login, login)
self.authenticate(login, login, browser=browser)
# Flush and clear the current transaction. This is useful in case
# we make requests to the server, as these requests are made with
# 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()
self._logger.info('Open "%s" in browser', url)
if self.browser.screencasts_dir:
if browser.screencasts_dir:
self._logger.info('Starting screencast')
self.browser.start_screencast()
browser.start_screencast()
if cookies:
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
# code = ""
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
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:
error = chrome_browser_exception
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))
finally:
# clear browser to make it stop sending requests, in case we call
# the method several times in a test method
self.browser.delete_cookie('session_id', domain=HOST)
self.browser.clear()
browser.stop()
self._wait_remaining_requests()
@classmethod