You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
225 lines
7.5 KiB
Python
225 lines
7.5 KiB
Python
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
|
|
"""
|
|
This module is meant to run JupyterLab in a headless browser, making sure
|
|
the application launches and starts up without errors.
|
|
"""
|
|
|
|
import asyncio
|
|
import inspect
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from os import path as osp
|
|
|
|
from jupyter_server.serverapp import aliases, flags
|
|
from jupyter_server.utils import pathname2url, urljoin
|
|
from tornado.ioloop import IOLoop
|
|
from tornado.iostream import StreamClosedError
|
|
from tornado.websocket import WebSocketClosedError
|
|
from traitlets import Bool, Unicode
|
|
|
|
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.StreamHandler):
|
|
"""A handler that exits with 1 on a logged error."""
|
|
|
|
def __init__(self):
|
|
super().__init__(stream=sys.stderr)
|
|
self.setLevel(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 record.exc_info is not None
|
|
and isinstance(record.exc_info[1], (StreamClosedError, WebSocketClosedError))
|
|
):
|
|
return False
|
|
return super().filter(record)
|
|
|
|
def emit(self, record):
|
|
self.errored = True
|
|
super().emit(record)
|
|
|
|
|
|
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()
|
|
|
|
app.log.info("Running async test")
|
|
|
|
# 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:
|
|
app.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(str(cmd) + " exited with " + str(proc.returncode))
|
|
return stdout, stderr
|
|
|
|
|
|
async def run_browser(url):
|
|
"""Run the browser test and return an exit code."""
|
|
browser = os.environ.get("JLAB_BROWSER_TYPE", "chromium")
|
|
if browser not in {"chromium", "firefox", "webkit"}:
|
|
browser = "chromium"
|
|
|
|
target = osp.join(get_app_dir(), "browser_test")
|
|
if not osp.exists(osp.join(target, "node_modules")):
|
|
if not osp.exists(target):
|
|
os.makedirs(osp.join(target))
|
|
await run_async_process(["npm", "init", "-y"], cwd=target)
|
|
await run_async_process(["npm", "install", "playwright@^1.9.2"], cwd=target)
|
|
await run_async_process(["npx", "playwright", "install", browser], cwd=target)
|
|
shutil.copy(osp.join(here, "browser-test.js"), osp.join(target, "browser-test.js"))
|
|
await run_async_process(["node", "browser-test.js", url], cwd=target)
|
|
|
|
|
|
def run_browser_sync(url):
|
|
"""Run the browser test and return an exit code."""
|
|
browser = os.environ.get("JLAB_BROWSER_TYPE", "chromium")
|
|
if browser not in {"chromium", "firefox", "webkit"}:
|
|
browser = "chromium"
|
|
|
|
target = osp.join(get_app_dir(), "browser_test")
|
|
if not osp.exists(osp.join(target, "node_modules")):
|
|
os.makedirs(target)
|
|
subprocess.call(["npm", "init", "-y"], cwd=target) # noqa S603 S607
|
|
subprocess.call(["npm", "install", "playwright@^1.9.2"], cwd=target) # noqa S603 S607
|
|
subprocess.call(["npx", "playwright", "install", browser], cwd=target) # noqa S603 S607
|
|
shutil.copy(osp.join(here, "browser-test.js"), osp.join(target, "browser-test.js"))
|
|
return subprocess.check_call(["node", "browser-test.js", url], cwd=target) # noqa S603 S607
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
name = __name__
|
|
open_browser = False
|
|
|
|
serverapp_config = {"base_url": "/foo/"}
|
|
default_url = Unicode("/lab?reset", config=True, help="The default URL to redirect to from `/`")
|
|
ip = "127.0.0.1"
|
|
flags = test_flags
|
|
aliases = test_aliases
|
|
test_browser = Bool(True)
|
|
|
|
def initialize_settings(self):
|
|
self.settings.setdefault("page_config_data", {})
|
|
self.settings["page_config_data"]["browserTest"] = True
|
|
self.settings["page_config_data"]["buildAvailable"] = False
|
|
self.settings["page_config_data"]["exposeAppInBrowser"] = True
|
|
super().initialize_settings()
|
|
|
|
def initialize_handlers(self):
|
|
def func(*args, **kwargs):
|
|
return 0
|
|
|
|
if self.test_browser:
|
|
func = run_browser_sync if os.name == "nt" else run_browser
|
|
|
|
run_test(self.serverapp, func)
|
|
super().initialize_handlers()
|
|
|
|
|
|
def _jupyter_server_extension_points():
|
|
return [{"module": __name__, "app": BrowserApp}]
|
|
|
|
|
|
def _jupyter_server_extension_paths():
|
|
return [{"module": "jupyterlab.browser_check"}]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
skip_options = ["--no-browser-test", "--no-chrome-test"]
|
|
for option in skip_options:
|
|
if option in sys.argv:
|
|
BrowserApp.test_browser = False
|
|
sys.argv.remove(option)
|
|
|
|
BrowserApp.launch_instance()
|