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.

634 lines
21 KiB

4 years ago
  1. # -*- coding: utf-8 -*-
  2. """Notebook Javascript Test Controller
  3. This module runs one or more subprocesses which will actually run the Javascript
  4. test suite.
  5. """
  6. # Copyright (c) Jupyter Development Team.
  7. # Distributed under the terms of the Modified BSD License.
  8. from __future__ import absolute_import, print_function
  9. import argparse
  10. import json
  11. import multiprocessing.pool
  12. import os
  13. import re
  14. import requests
  15. import signal
  16. import sys
  17. import subprocess
  18. import time
  19. from io import BytesIO
  20. from threading import Thread, Lock, Event
  21. try:
  22. from unittest.mock import patch
  23. except ImportError:
  24. from mock import patch # py3
  25. from jupyter_core.paths import jupyter_runtime_dir
  26. from ipython_genutils.py3compat import bytes_to_str, which
  27. from notebook._sysinfo import get_sys_info
  28. from ipython_genutils.tempdir import TemporaryDirectory
  29. try:
  30. # Python >= 3.3
  31. from subprocess import TimeoutExpired
  32. def popen_wait(p, timeout):
  33. return p.wait(timeout)
  34. except ImportError:
  35. class TimeoutExpired(Exception):
  36. pass
  37. def popen_wait(p, timeout):
  38. """backport of Popen.wait from Python 3"""
  39. for i in range(int(10 * timeout)):
  40. if p.poll() is not None:
  41. return
  42. time.sleep(0.1)
  43. if p.poll() is None:
  44. raise TimeoutExpired
  45. NOTEBOOK_SHUTDOWN_TIMEOUT = 10
  46. have = {}
  47. have['casperjs'] = bool(which('casperjs'))
  48. have['phantomjs'] = bool(which('phantomjs'))
  49. have['slimerjs'] = bool(which('slimerjs'))
  50. class StreamCapturer(Thread):
  51. daemon = True # Don't hang if main thread crashes
  52. started = False
  53. def __init__(self, echo=False):
  54. super(StreamCapturer, self).__init__()
  55. self.echo = echo
  56. self.streams = []
  57. self.buffer = BytesIO()
  58. self.readfd, self.writefd = os.pipe()
  59. self.buffer_lock = Lock()
  60. self.stop = Event()
  61. def run(self):
  62. self.started = True
  63. while not self.stop.is_set():
  64. chunk = os.read(self.readfd, 1024)
  65. with self.buffer_lock:
  66. self.buffer.write(chunk)
  67. if self.echo:
  68. sys.stdout.write(bytes_to_str(chunk))
  69. os.close(self.readfd)
  70. os.close(self.writefd)
  71. def reset_buffer(self):
  72. with self.buffer_lock:
  73. self.buffer.truncate(0)
  74. self.buffer.seek(0)
  75. def get_buffer(self):
  76. with self.buffer_lock:
  77. return self.buffer.getvalue()
  78. def ensure_started(self):
  79. if not self.started:
  80. self.start()
  81. def halt(self):
  82. """Safely stop the thread."""
  83. if not self.started:
  84. return
  85. self.stop.set()
  86. os.write(self.writefd, b'\0') # Ensure we're not locked in a read()
  87. self.join()
  88. class TestController(object):
  89. """Run tests in a subprocess
  90. """
  91. #: str, test group to be executed.
  92. section = None
  93. #: list, command line arguments to be executed
  94. cmd = None
  95. #: dict, extra environment variables to set for the subprocess
  96. env = None
  97. #: list, TemporaryDirectory instances to clear up when the process finishes
  98. dirs = None
  99. #: subprocess.Popen instance
  100. process = None
  101. #: str, process stdout+stderr
  102. stdout = None
  103. def __init__(self):
  104. self.cmd = []
  105. self.env = {}
  106. self.dirs = []
  107. def setup(self):
  108. """Create temporary directories etc.
  109. This is only called when we know the test group will be run. Things
  110. created here may be cleaned up by self.cleanup().
  111. """
  112. pass
  113. def launch(self, buffer_output=False, capture_output=False):
  114. # print('*** ENV:', self.env) # dbg
  115. # print('*** CMD:', self.cmd) # dbg
  116. env = os.environ.copy()
  117. env.update(self.env)
  118. if buffer_output:
  119. capture_output = True
  120. self.stdout_capturer = c = StreamCapturer(echo=not buffer_output)
  121. c.start()
  122. stdout = c.writefd if capture_output else None
  123. stderr = subprocess.STDOUT if capture_output else None
  124. self.process = subprocess.Popen(self.cmd, stdout=stdout,
  125. stderr=stderr, env=env)
  126. def wait(self):
  127. self.process.wait()
  128. self.stdout_capturer.halt()
  129. self.stdout = self.stdout_capturer.get_buffer()
  130. return self.process.returncode
  131. def print_extra_info(self):
  132. """Print extra information about this test run.
  133. If we're running in parallel and showing the concise view, this is only
  134. called if the test group fails. Otherwise, it's called before the test
  135. group is started.
  136. The base implementation does nothing, but it can be overridden by
  137. subclasses.
  138. """
  139. return
  140. def cleanup_process(self):
  141. """Cleanup on exit by killing any leftover processes."""
  142. subp = self.process
  143. if subp is None or (subp.poll() is not None):
  144. return # Process doesn't exist, or is already dead.
  145. try:
  146. print('Cleaning up stale PID: %d' % subp.pid)
  147. subp.kill()
  148. except: # (OSError, WindowsError) ?
  149. # This is just a best effort, if we fail or the process was
  150. # really gone, ignore it.
  151. pass
  152. else:
  153. for i in range(10):
  154. if subp.poll() is None:
  155. time.sleep(0.1)
  156. else:
  157. break
  158. if subp.poll() is None:
  159. # The process did not die...
  160. print('... failed. Manual cleanup may be required.')
  161. def cleanup(self):
  162. "Kill process if it's still alive, and clean up temporary directories"
  163. self.cleanup_process()
  164. for td in self.dirs:
  165. td.cleanup()
  166. __del__ = cleanup
  167. def get_js_test_dir():
  168. import notebook.tests as t
  169. return os.path.join(os.path.dirname(t.__file__), '')
  170. def all_js_groups():
  171. import glob
  172. test_dir = get_js_test_dir()
  173. all_subdirs = glob.glob(test_dir + '[!_]*/')
  174. return [os.path.relpath(x, test_dir) for x in all_subdirs]
  175. class JSController(TestController):
  176. """Run CasperJS tests """
  177. requirements = ['casperjs']
  178. def __init__(self, section, xunit=True, engine='phantomjs', url=None):
  179. """Create new test runner."""
  180. TestController.__init__(self)
  181. self.engine = engine
  182. self.section = section
  183. self.xunit = xunit
  184. self.url = url
  185. # run with a base URL that would be escaped,
  186. # to test that we don't double-escape URLs
  187. self.base_url = '/a@b/'
  188. self.slimer_failure = re.compile('^FAIL.*', flags=re.MULTILINE)
  189. js_test_dir = get_js_test_dir()
  190. includes = '--includes=' + os.path.join(js_test_dir,'util.js')
  191. test_cases = os.path.join(js_test_dir, self.section)
  192. self.cmd = ['casperjs', 'test', includes, test_cases, '--engine=%s' % self.engine]
  193. def setup(self):
  194. self.ipydir = TemporaryDirectory()
  195. self.config_dir = TemporaryDirectory()
  196. self.nbdir = TemporaryDirectory()
  197. self.home = TemporaryDirectory()
  198. self.env = {
  199. 'HOME': self.home.name,
  200. 'JUPYTER_CONFIG_DIR': self.config_dir.name,
  201. 'IPYTHONDIR': self.ipydir.name,
  202. }
  203. self.dirs.append(self.ipydir)
  204. self.dirs.append(self.home)
  205. self.dirs.append(self.config_dir)
  206. self.dirs.append(self.nbdir)
  207. os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub ∂ir1', u'sub ∂ir 1a')))
  208. os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub ∂ir2', u'sub ∂ir 1b')))
  209. if self.xunit:
  210. self.add_xunit()
  211. # If a url was specified, use that for the testing.
  212. if self.url:
  213. try:
  214. alive = requests.get(self.url).status_code == 200
  215. except:
  216. alive = False
  217. if alive:
  218. self.cmd.append("--url=%s" % self.url)
  219. else:
  220. raise Exception('Could not reach "%s".' % self.url)
  221. else:
  222. # start the ipython notebook, so we get the port number
  223. self.server_port = 0
  224. self._init_server()
  225. if self.server_port:
  226. self.cmd.append('--url=http://localhost:%i%s' % (self.server_port, self.base_url))
  227. else:
  228. # don't launch tests if the server didn't start
  229. self.cmd = [sys.executable, '-c', 'raise SystemExit(1)']
  230. def add_xunit(self):
  231. xunit_file = os.path.abspath(self.section.replace('/','.') + '.xunit.xml')
  232. self.cmd.append('--xunit=%s' % xunit_file)
  233. def launch(self, buffer_output):
  234. # If the engine is SlimerJS, we need to buffer the output because
  235. # SlimerJS does not support exit codes, so CasperJS always returns 0.
  236. if self.engine == 'slimerjs' and not buffer_output:
  237. return super(JSController, self).launch(capture_output=True)
  238. else:
  239. return super(JSController, self).launch(buffer_output=buffer_output)
  240. def wait(self, *pargs, **kwargs):
  241. """Wait for the JSController to finish"""
  242. ret = super(JSController, self).wait(*pargs, **kwargs)
  243. # If this is a SlimerJS controller, check the captured stdout for
  244. # errors. Otherwise, just return the return code.
  245. if self.engine == 'slimerjs':
  246. stdout = bytes_to_str(self.stdout)
  247. if ret != 0:
  248. # This could still happen e.g. if it's stopped by SIGINT
  249. return ret
  250. return bool(self.slimer_failure.search(stdout))
  251. else:
  252. return ret
  253. def print_extra_info(self):
  254. print("Running tests with notebook directory %r" % self.nbdir.name)
  255. @property
  256. def will_run(self):
  257. should_run = all(have[a] for a in self.requirements + [self.engine])
  258. return should_run
  259. def _init_server(self):
  260. "Start the notebook server in a separate process"
  261. self.server_command = command = [sys.executable,
  262. '-m', 'notebook',
  263. '--no-browser',
  264. '--notebook-dir', self.nbdir.name,
  265. '--NotebookApp.token=',
  266. '--NotebookApp.base_url=%s' % self.base_url,
  267. ]
  268. # ipc doesn't work on Windows, and darwin has crazy-long temp paths,
  269. # which run afoul of ipc's maximum path length.
  270. if sys.platform.startswith('linux'):
  271. command.append('--KernelManager.transport=ipc')
  272. self.stream_capturer = c = StreamCapturer()
  273. c.start()
  274. env = os.environ.copy()
  275. env.update(self.env)
  276. self.server = subprocess.Popen(command,
  277. stdout = c.writefd,
  278. stderr = subprocess.STDOUT,
  279. cwd=self.nbdir.name,
  280. env=env,
  281. )
  282. with patch.dict('os.environ', {'HOME': self.home.name}):
  283. runtime_dir = jupyter_runtime_dir()
  284. self.server_info_file = os.path.join(runtime_dir,
  285. 'nbserver-%i.json' % self.server.pid
  286. )
  287. self._wait_for_server()
  288. def _wait_for_server(self):
  289. """Wait 30 seconds for the notebook server to start"""
  290. for i in range(300):
  291. if self.server.poll() is not None:
  292. return self._failed_to_start()
  293. if os.path.exists(self.server_info_file):
  294. try:
  295. self._load_server_info()
  296. except ValueError:
  297. # If the server is halfway through writing the file, we may
  298. # get invalid JSON; it should be ready next iteration.
  299. pass
  300. else:
  301. return
  302. time.sleep(0.1)
  303. print("Notebook server-info file never arrived: %s" % self.server_info_file,
  304. file=sys.stderr
  305. )
  306. def _failed_to_start(self):
  307. """Notebook server exited prematurely"""
  308. captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace')
  309. print("Notebook failed to start: ", file=sys.stderr)
  310. print(self.server_command)
  311. print(captured, file=sys.stderr)
  312. def _load_server_info(self):
  313. """Notebook server started, load connection info from JSON"""
  314. with open(self.server_info_file) as f:
  315. info = json.load(f)
  316. self.server_port = info['port']
  317. def cleanup(self):
  318. if hasattr(self, 'server'):
  319. try:
  320. self.server.terminate()
  321. except OSError:
  322. # already dead
  323. pass
  324. # wait 10s for the server to shutdown
  325. try:
  326. popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
  327. except TimeoutExpired:
  328. # server didn't terminate, kill it
  329. try:
  330. print("Failed to terminate notebook server, killing it.",
  331. file=sys.stderr
  332. )
  333. self.server.kill()
  334. except OSError:
  335. # already dead
  336. pass
  337. # wait another 10s
  338. try:
  339. popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
  340. except TimeoutExpired:
  341. print("Notebook server still running (%s)" % self.server_info_file,
  342. file=sys.stderr
  343. )
  344. self.stream_capturer.halt()
  345. TestController.cleanup(self)
  346. def prepare_controllers(options):
  347. """Returns two lists of TestController instances, those to run, and those
  348. not to run."""
  349. testgroups = options.testgroups
  350. if not testgroups:
  351. testgroups = all_js_groups()
  352. engine = 'slimerjs' if options.slimerjs else 'phantomjs'
  353. c_js = [JSController(name, xunit=options.xunit, engine=engine, url=options.url) for name in testgroups]
  354. controllers = c_js
  355. to_run = [c for c in controllers if c.will_run]
  356. not_run = [c for c in controllers if not c.will_run]
  357. return to_run, not_run
  358. def do_run(controller, buffer_output=True):
  359. """Setup and run a test controller.
  360. If buffer_output is True, no output is displayed, to avoid it appearing
  361. interleaved. In this case, the caller is responsible for displaying test
  362. output on failure.
  363. Returns
  364. -------
  365. controller : TestController
  366. The same controller as passed in, as a convenience for using map() type
  367. APIs.
  368. exitcode : int
  369. The exit code of the test subprocess. Non-zero indicates failure.
  370. """
  371. try:
  372. try:
  373. controller.setup()
  374. if not buffer_output:
  375. controller.print_extra_info()
  376. controller.launch(buffer_output=buffer_output)
  377. except Exception:
  378. import traceback
  379. traceback.print_exc()
  380. return controller, 1 # signal failure
  381. exitcode = controller.wait()
  382. return controller, exitcode
  383. except KeyboardInterrupt:
  384. return controller, -signal.SIGINT
  385. finally:
  386. controller.cleanup()
  387. def report():
  388. """Return a string with a summary report of test-related variables."""
  389. inf = get_sys_info()
  390. out = []
  391. def _add(name, value):
  392. out.append((name, value))
  393. _add('Python version', inf['sys_version'].replace('\n',''))
  394. _add('sys.executable', inf['sys_executable'])
  395. _add('Platform', inf['platform'])
  396. width = max(len(n) for (n,v) in out)
  397. out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
  398. avail = []
  399. not_avail = []
  400. for k, is_avail in have.items():
  401. if is_avail:
  402. avail.append(k)
  403. else:
  404. not_avail.append(k)
  405. if avail:
  406. out.append('\nTools and libraries available at test time:\n')
  407. avail.sort()
  408. out.append(' ' + ' '.join(avail)+'\n')
  409. if not_avail:
  410. out.append('\nTools and libraries NOT available at test time:\n')
  411. not_avail.sort()
  412. out.append(' ' + ' '.join(not_avail)+'\n')
  413. return ''.join(out)
  414. def run_jstestall(options):
  415. """Run the entire Javascript test suite.
  416. This function constructs TestControllers and runs them in subprocesses.
  417. Parameters
  418. ----------
  419. All parameters are passed as attributes of the options object.
  420. testgroups : list of str
  421. Run only these sections of the test suite. If empty, run all the available
  422. sections.
  423. fast : int or None
  424. Run the test suite in parallel, using n simultaneous processes. If None
  425. is passed, one process is used per CPU core. Default 1 (i.e. sequential)
  426. inc_slow : bool
  427. Include slow tests. By default, these tests aren't run.
  428. slimerjs : bool
  429. Use slimerjs if it's installed instead of phantomjs for casperjs tests.
  430. url : unicode
  431. Address:port to use when running the JS tests.
  432. xunit : bool
  433. Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
  434. extra_args : list
  435. Extra arguments to pass to the test subprocesses, e.g. '-v'
  436. """
  437. to_run, not_run = prepare_controllers(options)
  438. def justify(ltext, rtext, width=70, fill='-'):
  439. ltext += ' '
  440. rtext = (' ' + rtext).rjust(width - len(ltext), fill)
  441. return ltext + rtext
  442. # Run all test runners, tracking execution time
  443. failed = []
  444. t_start = time.time()
  445. print()
  446. if options.fast == 1:
  447. # This actually means sequential, i.e. with 1 job
  448. for controller in to_run:
  449. print('Test group:', controller.section)
  450. sys.stdout.flush() # Show in correct order when output is piped
  451. controller, res = do_run(controller, buffer_output=False)
  452. if res:
  453. failed.append(controller)
  454. if res == -signal.SIGINT:
  455. print("Interrupted")
  456. break
  457. print()
  458. else:
  459. # Run tests concurrently
  460. try:
  461. pool = multiprocessing.pool.ThreadPool(options.fast)
  462. for (controller, res) in pool.imap_unordered(do_run, to_run):
  463. res_string = 'OK' if res == 0 else 'FAILED'
  464. print(justify('Test group: ' + controller.section, res_string))
  465. if res:
  466. controller.print_extra_info()
  467. print(bytes_to_str(controller.stdout))
  468. failed.append(controller)
  469. if res == -signal.SIGINT:
  470. print("Interrupted")
  471. break
  472. except KeyboardInterrupt:
  473. return
  474. for controller in not_run:
  475. print(justify('Test group: ' + controller.section, 'NOT RUN'))
  476. t_end = time.time()
  477. t_tests = t_end - t_start
  478. nrunners = len(to_run)
  479. nfail = len(failed)
  480. # summarize results
  481. print('_'*70)
  482. print('Test suite completed for system with the following information:')
  483. print(report())
  484. took = "Took %.3fs." % t_tests
  485. print('Status: ', end='')
  486. if not failed:
  487. print('OK (%d test groups).' % nrunners, took)
  488. else:
  489. # If anything went wrong, point out what command to rerun manually to
  490. # see the actual errors and individual summary
  491. failed_sections = [c.section for c in failed]
  492. print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
  493. nrunners, ', '.join(failed_sections)), took)
  494. print()
  495. print('You may wish to rerun these, with:')
  496. print(' python -m notebook.jstest', *failed_sections)
  497. print()
  498. if failed:
  499. # Ensure that our exit code indicates failure
  500. sys.exit(1)
  501. argparser = argparse.ArgumentParser(description='Run Jupyter Notebook Javascript tests')
  502. argparser.add_argument('testgroups', nargs='*',
  503. help='Run specified groups of tests. If omitted, run '
  504. 'all tests.')
  505. argparser.add_argument('--slimerjs', action='store_true',
  506. help="Use slimerjs if it's installed instead of phantomjs for casperjs tests.")
  507. argparser.add_argument('--url', help="URL to use for the JS tests.")
  508. argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
  509. help='Run test sections in parallel. This starts as many '
  510. 'processes as you have cores, or you can specify a number.')
  511. argparser.add_argument('--xunit', action='store_true',
  512. help='Produce Xunit XML results')
  513. argparser.add_argument('--subproc-streams', default='capture',
  514. help="What to do with stdout/stderr from subprocesses. "
  515. "'capture' (default), 'show' and 'discard' are the options.")
  516. def default_options():
  517. """Get an argparse Namespace object with the default arguments, to pass to
  518. :func:`run_iptestall`.
  519. """
  520. options = argparser.parse_args([])
  521. options.extra_args = []
  522. return options
  523. def main():
  524. try:
  525. ix = sys.argv.index('--')
  526. except ValueError:
  527. to_parse = sys.argv[1:]
  528. extra_args = []
  529. else:
  530. to_parse = sys.argv[1:ix]
  531. extra_args = sys.argv[ix+1:]
  532. options = argparser.parse_args(to_parse)
  533. options.extra_args = extra_args
  534. run_jstestall(options)
  535. if __name__ == '__main__':
  536. main()