browser_check.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # -*- coding: utf-8 -*-
  2. """
  3. This module is meant to run JupyterLab in a headless browser, making sure
  4. the application launches and starts up without errors.
  5. """
  6. import asyncio
  7. from concurrent.futures import ThreadPoolExecutor
  8. import inspect
  9. import logging
  10. from os import path as osp
  11. import os
  12. import shutil
  13. import subprocess
  14. import sys
  15. import time
  16. from tornado.ioloop import IOLoop
  17. from tornado.iostream import StreamClosedError
  18. from tornado.websocket import WebSocketClosedError
  19. from jupyter_server.serverapp import flags, aliases
  20. from jupyter_server.utils import urljoin, pathname2url
  21. from traitlets import Bool
  22. from .labapp import LabApp, get_app_dir
  23. from .tests.test_app import TestEnv
  24. here = osp.abspath(osp.dirname(__file__))
  25. test_flags = dict(flags)
  26. test_flags['core-mode'] = (
  27. {'BrowserApp': {'core_mode': True}},
  28. "Start the app in core mode."
  29. )
  30. test_flags['dev-mode'] = (
  31. {'BrowserApp': {'dev_mode': True}},
  32. "Start the app in dev mode."
  33. )
  34. test_flags['watch'] = (
  35. {'BrowserApp': {'watch': True}},
  36. "Start the app in watch mode."
  37. )
  38. test_aliases = dict(aliases)
  39. test_aliases['app-dir'] = 'BrowserApp.app_dir'
  40. class LogErrorHandler(logging.Handler):
  41. """A handler that exits with 1 on a logged error."""
  42. def __init__(self):
  43. super().__init__(level=logging.ERROR)
  44. self.errored = False
  45. def filter(self, record):
  46. # Handle known StreamClosedError from Tornado
  47. # These occur when we forcibly close Websockets or
  48. # browser connections during the test.
  49. # https://github.com/tornadoweb/tornado/issues/2834
  50. if hasattr(record, 'exc_info') and not record.exc_info is None and isinstance(record.exc_info[1], (StreamClosedError, WebSocketClosedError)):
  51. return
  52. return super().filter(record)
  53. def emit(self, record):
  54. print(record.msg, file=sys.stderr)
  55. self.errored = True
  56. def run_test(app, func):
  57. """Synchronous entry point to run a test function.
  58. func is a function that accepts an app url as a parameter and returns a result.
  59. func can be synchronous or asynchronous. If it is synchronous, it will be run
  60. in a thread, so asynchronous is preferred.
  61. """
  62. IOLoop.current().spawn_callback(run_test_async, app, func)
  63. async def run_test_async(app, func):
  64. """Run a test against the application.
  65. func is a function that accepts an app url as a parameter and returns a result.
  66. func can be synchronous or asynchronous. If it is synchronous, it will be run
  67. in a thread, so asynchronous is preferred.
  68. """
  69. handler = LogErrorHandler()
  70. app.log.addHandler(handler)
  71. env_patch = TestEnv()
  72. env_patch.start()
  73. app.log.info('Running async test')
  74. # The entry URL for browser tests is different in notebook >= 6.0,
  75. # since that uses a local HTML file to point the user at the app.
  76. if hasattr(app, 'browser_open_file'):
  77. url = urljoin('file:', pathname2url(app.browser_open_file))
  78. else:
  79. url = app.display_url
  80. # Allow a synchronous function to be passed in.
  81. if inspect.iscoroutinefunction(func):
  82. test = func(url)
  83. else:
  84. app.log.info('Using thread pool executor to run test')
  85. loop = asyncio.get_event_loop()
  86. executor = ThreadPoolExecutor()
  87. task = loop.run_in_executor(executor, func, url)
  88. test = asyncio.wait([task])
  89. try:
  90. await test
  91. except Exception as e:
  92. app.log.critical("Caught exception during the test:")
  93. app.log.error(str(e))
  94. app.log.info("Test Complete")
  95. result = 0
  96. if handler.errored:
  97. result = 1
  98. app.log.critical('Exiting with 1 due to errors')
  99. else:
  100. app.log.info('Exiting normally')
  101. app.log.info('Stopping server...')
  102. try:
  103. app.http_server.stop()
  104. app.io_loop.stop()
  105. env_patch.stop()
  106. except Exception as e:
  107. app.log.error(str(e))
  108. result = 1
  109. finally:
  110. time.sleep(2)
  111. os._exit(result)
  112. async def run_async_process(cmd, **kwargs):
  113. """Run an asynchronous command"""
  114. proc = await asyncio.create_subprocess_exec(
  115. *cmd,
  116. **kwargs)
  117. stdout, stderr = await proc.communicate()
  118. if proc.returncode != 0:
  119. raise RuntimeError(str(cmd) + ' exited with ' + str(proc.returncode))
  120. return stdout, stderr
  121. async def run_browser(url):
  122. """Run the browser test and return an exit code.
  123. """
  124. target = osp.join(get_app_dir(), 'browser_test')
  125. if not osp.exists(osp.join(target, 'node_modules')):
  126. if not osp.exists(target):
  127. os.makedirs(osp.join(target))
  128. await run_async_process(["jlpm", "init", "-y"], cwd=target)
  129. await run_async_process(["jlpm", "add", "puppeteer@^4"], cwd=target)
  130. shutil.copy(osp.join(here, 'chrome-test.js'), osp.join(target, 'chrome-test.js'))
  131. await run_async_process(["node", "chrome-test.js", url], cwd=target)
  132. def run_browser_sync(url):
  133. """Run the browser test and return an exit code.
  134. """
  135. target = osp.join(get_app_dir(), 'browser_test')
  136. if not osp.exists(osp.join(target, 'node_modules')):
  137. os.makedirs(target)
  138. subprocess.call(["jlpm", "init", "-y"], cwd=target)
  139. subprocess.call(["jlpm", "add", "puppeteer@^2"], cwd=target)
  140. shutil.copy(osp.join(here, 'chrome-test.js'), osp.join(target, 'chrome-test.js'))
  141. return subprocess.check_call(["node", "chrome-test.js", url], cwd=target)
  142. class BrowserApp(LabApp):
  143. """An app the launches JupyterLab and waits for it to start up, checking for
  144. JS console errors, JS errors, and Python logged errors.
  145. """
  146. name = __name__
  147. serverapp_config = {
  148. "open_browser": False,
  149. "base_url": "/foo/"
  150. }
  151. ip = '127.0.0.1'
  152. flags = test_flags
  153. aliases = test_aliases
  154. test_browser = Bool(True)
  155. def initialize_settings(self):
  156. self.settings.setdefault('page_config_data', dict())
  157. self.settings['page_config_data']['browserTest'] = True
  158. self.settings['page_config_data']['buildAvailable'] = False
  159. self.settings['page_config_data']['exposeAppInBrowser'] = True
  160. super().initialize_settings()
  161. def initialize_handlers(self):
  162. func = run_browser if self.test_browser else lambda url: 0
  163. if os.name == 'nt' and func == run_browser:
  164. func = run_browser_sync
  165. run_test(self.serverapp, func)
  166. super().initialize_handlers()
  167. def _jupyter_server_extension_points():
  168. return [
  169. {
  170. 'module': __name__,
  171. 'app': BrowserApp
  172. }
  173. ]
  174. # TODO: remove handling of --notebook arg and the following two
  175. # functions in JupyterLab 4.0
  176. def load_jupyter_server_extension(serverapp):
  177. extension = BrowserApp()
  178. extension.serverapp = serverapp
  179. extension.load_config_file()
  180. extension.update_config(serverapp.config)
  181. extension.parse_command_line(serverapp.extra_args)
  182. extension.initialize()
  183. def _jupyter_server_extension_paths():
  184. return [
  185. {
  186. 'module': 'jupyterlab.browser_check'
  187. }
  188. ]
  189. if __name__ == '__main__':
  190. skip_option = "--no-chrome-test"
  191. if skip_option in sys.argv:
  192. BrowserApp.test_browser = False
  193. sys.argv.remove(skip_option)
  194. if "--notebook" in sys.argv:
  195. from notebook.notebookapp import NotebookApp
  196. NotebookApp.default_url = "/lab"
  197. sys.argv.remove("--notebook")
  198. NotebookApp.nbserver_extensions = {"jupyterlab.browser_check": True}
  199. NotebookApp.open_browser = False
  200. NotebookApp.launch_instance()
  201. else:
  202. BrowserApp.launch_instance()