You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
191 lines
5.6 KiB
191 lines
5.6 KiB
4 years ago
|
|
||
|
# -*- coding: utf-8 -*-
|
||
|
"""
|
||
|
This module is meant to run JupyterLab in a headless browser, making sure
|
||
|
the application launches and starts up without errors.
|
||
|
"""
|
||
|
import asyncio
|
||
|
from concurrent.futures import ThreadPoolExecutor
|
||
|
import inspect
|
||
|
import logging
|
||
|
from os import path as osp
|
||
|
import os
|
||
|
import shutil
|
||
|
import sys
|
||
|
import time
|
||
|
|
||
|
from tornado.ioloop import IOLoop
|
||
|
from tornado.iostream import StreamClosedError
|
||
|
from tornado.websocket import WebSocketClosedError
|
||
|
|
||
|
from notebook.notebookapp import flags, aliases
|
||
|
from notebook.utils import urljoin, pathname2url
|
||
|
from traitlets import Bool
|
||
|
|
||
|
from .labapp import LabApp, get_app_dir
|
||
|
from .tests.test_app import TestEnv
|
||
|
|
||
|
|
||
|
here = osp.abspath(osp.dirname(__file__))
|
||
|
test_flags = dict(flags)
|
||
|
test_flags['core-mode'] = (
|
||
|
{'BrowserApp': {'core_mode': True}},
|
||
|
"Start the app in core mode."
|
||
|
)
|
||
|
test_flags['dev-mode'] = (
|
||
|
{'BrowserApp': {'dev_mode': True}},
|
||
|
"Start the app in dev mode."
|
||
|
)
|
||
|
test_flags['watch'] = (
|
||
|
{'BrowserApp': {'watch': True}},
|
||
|
"Start the app in watch mode."
|
||
|
)
|
||
|
|
||
|
test_aliases = dict(aliases)
|
||
|
test_aliases['app-dir'] = 'BrowserApp.app_dir'
|
||
|
|
||
|
|
||
|
class LogErrorHandler(logging.Handler):
|
||
|
"""A handler that exits with 1 on a logged error."""
|
||
|
|
||
|
def __init__(self):
|
||
|
super().__init__(level=logging.ERROR)
|
||
|
self.errored = False
|
||
|
|
||
|
def filter(self, record):
|
||
|
# Handle known StreamClosedError from Tornado
|
||
|
# These occur when we forcibly close Websockets or
|
||
|
# browser connections during the test.
|
||
|
# https://github.com/tornadoweb/tornado/issues/2834
|
||
|
if hasattr(record, 'exc_info') and not record.exc_info is None and isinstance(record.exc_info[1], (StreamClosedError, WebSocketClosedError)):
|
||
|
return
|
||
|
return super().filter(record)
|
||
|
|
||
|
def emit(self, record):
|
||
|
print(record.msg, file=sys.stderr)
|
||
|
self.errored = True
|
||
|
|
||
|
|
||
|
def run_test(app, func):
|
||
|
"""Synchronous entry point to run a test function.
|
||
|
|
||
|
func is a function that accepts an app url as a parameter and returns a result.
|
||
|
|
||
|
func can be synchronous or asynchronous. If it is synchronous, it will be run
|
||
|
in a thread, so asynchronous is preferred.
|
||
|
"""
|
||
|
IOLoop.current().spawn_callback(run_test_async, app, func)
|
||
|
|
||
|
|
||
|
async def run_test_async(app, func):
|
||
|
"""Run a test against the application.
|
||
|
|
||
|
func is a function that accepts an app url as a parameter and returns a result.
|
||
|
|
||
|
func can be synchronous or asynchronous. If it is synchronous, it will be run
|
||
|
in a thread, so asynchronous is preferred.
|
||
|
"""
|
||
|
handler = LogErrorHandler()
|
||
|
app.log.addHandler(handler)
|
||
|
|
||
|
env_patch = TestEnv()
|
||
|
env_patch.start()
|
||
|
|
||
|
# The entry URL for browser tests is different in notebook >= 6.0,
|
||
|
# since that uses a local HTML file to point the user at the app.
|
||
|
if hasattr(app, 'browser_open_file'):
|
||
|
url = urljoin('file:', pathname2url(app.browser_open_file))
|
||
|
else:
|
||
|
url = app.display_url
|
||
|
|
||
|
# Allow a synchronous function to be passed in.
|
||
|
if inspect.iscoroutinefunction(func):
|
||
|
test = func(url)
|
||
|
else:
|
||
|
app.log.info('Using thread pool executor to run test')
|
||
|
loop = asyncio.get_event_loop()
|
||
|
executor = ThreadPoolExecutor()
|
||
|
task = loop.run_in_executor(executor, func, url)
|
||
|
test = asyncio.wait([task])
|
||
|
|
||
|
try:
|
||
|
await test
|
||
|
except Exception as e:
|
||
|
app.log.critical("Caught exception during the test:")
|
||
|
app.log.error(str(e))
|
||
|
|
||
|
app.log.info("Test Complete")
|
||
|
|
||
|
result = 0
|
||
|
if handler.errored:
|
||
|
result = 1
|
||
|
app.log.critical('Exiting with 1 due to errors')
|
||
|
else:
|
||
|
app.log.info('Exiting normally')
|
||
|
|
||
|
app.log.info('Stopping server...')
|
||
|
try:
|
||
|
app.http_server.stop()
|
||
|
app.io_loop.stop()
|
||
|
env_patch.stop()
|
||
|
except Exception as e:
|
||
|
self.log.error(str(e))
|
||
|
result = 1
|
||
|
finally:
|
||
|
time.sleep(2)
|
||
|
os._exit(result)
|
||
|
|
||
|
|
||
|
async def run_async_process(cmd, **kwargs):
|
||
|
"""Run an asynchronous command"""
|
||
|
proc = await asyncio.create_subprocess_exec(
|
||
|
*cmd,
|
||
|
**kwargs)
|
||
|
stdout, stderr = await proc.communicate()
|
||
|
if proc.returncode != 0:
|
||
|
raise RuntimeError(cmd + ' exited with ' + str(proc.returncode))
|
||
|
return stdout, stderr
|
||
|
|
||
|
|
||
|
async def run_browser(url):
|
||
|
"""Run the browser test and return an exit code.
|
||
|
"""
|
||
|
target = osp.join(get_app_dir(), 'browser_test')
|
||
|
if not osp.exists(osp.join(target, 'node_modules')):
|
||
|
os.makedirs(target)
|
||
|
await run_async_process(["jlpm", "init", "-y"], cwd=target)
|
||
|
await run_async_process(["jlpm", "add", "puppeteer@^2"], cwd=target)
|
||
|
shutil.copy(osp.join(here, 'chrome-test.js'), osp.join(target, 'chrome-test.js'))
|
||
|
await run_async_process(["node", "chrome-test.js", url], cwd=target)
|
||
|
|
||
|
|
||
|
class BrowserApp(LabApp):
|
||
|
"""An app the launches JupyterLab and waits for it to start up, checking for
|
||
|
JS console errors, JS errors, and Python logged errors.
|
||
|
"""
|
||
|
open_browser = Bool(False)
|
||
|
base_url = '/foo/'
|
||
|
ip = '127.0.0.1'
|
||
|
flags = test_flags
|
||
|
aliases = test_aliases
|
||
|
test_browser = Bool(True)
|
||
|
|
||
|
def start(self):
|
||
|
web_app = self.web_app
|
||
|
self.kernel_manager.shutdown_wait_time = 1
|
||
|
|
||
|
web_app.settings.setdefault('page_config_data', dict())
|
||
|
web_app.settings['page_config_data']['browserTest'] = True
|
||
|
web_app.settings['page_config_data']['buildAvailable'] = False
|
||
|
func = run_browser if self.test_browser else lambda url: 0
|
||
|
run_test(self, func)
|
||
|
super().start()
|
||
|
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
skip_option = "--no-chrome-test"
|
||
|
if skip_option in sys.argv:
|
||
|
BrowserApp.test_browser = False
|
||
|
sys.argv.remove(skip_option)
|
||
|
BrowserApp.launch_instance()
|