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()
 |