##// END OF EJS Templates
Restore the ability to run all js tests with iptest js...
Thomas Kluyver -
Show More
@@ -1,738 +1,741 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 """IPython Test Process Controller
2 """IPython Test Process Controller
3
3
4 This module runs one or more subprocesses which will actually run the IPython
4 This module runs one or more subprocesses which will actually run the IPython
5 test suite.
5 test suite.
6
6
7 """
7 """
8
8
9 # Copyright (c) IPython Development Team.
9 # Copyright (c) IPython Development Team.
10 # Distributed under the terms of the Modified BSD License.
10 # Distributed under the terms of the Modified BSD License.
11
11
12 from __future__ import print_function
12 from __future__ import print_function
13
13
14 import argparse
14 import argparse
15 import json
15 import json
16 import multiprocessing.pool
16 import multiprocessing.pool
17 import os
17 import os
18 import stat
18 import stat
19 import re
19 import re
20 import requests
20 import requests
21 import shutil
21 import shutil
22 import signal
22 import signal
23 import sys
23 import sys
24 import subprocess
24 import subprocess
25 import time
25 import time
26
26
27 from .iptest import (
27 from .iptest import (
28 have, test_group_names as py_test_group_names, test_sections, StreamCapturer,
28 have, test_group_names as py_test_group_names, test_sections, StreamCapturer,
29 test_for,
29 test_for,
30 )
30 )
31 from IPython.utils.path import compress_user
31 from IPython.utils.path import compress_user
32 from IPython.utils.py3compat import bytes_to_str
32 from IPython.utils.py3compat import bytes_to_str
33 from IPython.utils.sysinfo import get_sys_info
33 from IPython.utils.sysinfo import get_sys_info
34 from IPython.utils.tempdir import TemporaryDirectory
34 from IPython.utils.tempdir import TemporaryDirectory
35 from IPython.utils.text import strip_ansi
35 from IPython.utils.text import strip_ansi
36
36
37 try:
37 try:
38 # Python >= 3.3
38 # Python >= 3.3
39 from subprocess import TimeoutExpired
39 from subprocess import TimeoutExpired
40 def popen_wait(p, timeout):
40 def popen_wait(p, timeout):
41 return p.wait(timeout)
41 return p.wait(timeout)
42 except ImportError:
42 except ImportError:
43 class TimeoutExpired(Exception):
43 class TimeoutExpired(Exception):
44 pass
44 pass
45 def popen_wait(p, timeout):
45 def popen_wait(p, timeout):
46 """backport of Popen.wait from Python 3"""
46 """backport of Popen.wait from Python 3"""
47 for i in range(int(10 * timeout)):
47 for i in range(int(10 * timeout)):
48 if p.poll() is not None:
48 if p.poll() is not None:
49 return
49 return
50 time.sleep(0.1)
50 time.sleep(0.1)
51 if p.poll() is None:
51 if p.poll() is None:
52 raise TimeoutExpired
52 raise TimeoutExpired
53
53
54 NOTEBOOK_SHUTDOWN_TIMEOUT = 10
54 NOTEBOOK_SHUTDOWN_TIMEOUT = 10
55
55
56 class TestController(object):
56 class TestController(object):
57 """Run tests in a subprocess
57 """Run tests in a subprocess
58 """
58 """
59 #: str, IPython test suite to be executed.
59 #: str, IPython test suite to be executed.
60 section = None
60 section = None
61 #: list, command line arguments to be executed
61 #: list, command line arguments to be executed
62 cmd = None
62 cmd = None
63 #: dict, extra environment variables to set for the subprocess
63 #: dict, extra environment variables to set for the subprocess
64 env = None
64 env = None
65 #: list, TemporaryDirectory instances to clear up when the process finishes
65 #: list, TemporaryDirectory instances to clear up when the process finishes
66 dirs = None
66 dirs = None
67 #: subprocess.Popen instance
67 #: subprocess.Popen instance
68 process = None
68 process = None
69 #: str, process stdout+stderr
69 #: str, process stdout+stderr
70 stdout = None
70 stdout = None
71
71
72 def __init__(self):
72 def __init__(self):
73 self.cmd = []
73 self.cmd = []
74 self.env = {}
74 self.env = {}
75 self.dirs = []
75 self.dirs = []
76
76
77 def setup(self):
77 def setup(self):
78 """Create temporary directories etc.
78 """Create temporary directories etc.
79
79
80 This is only called when we know the test group will be run. Things
80 This is only called when we know the test group will be run. Things
81 created here may be cleaned up by self.cleanup().
81 created here may be cleaned up by self.cleanup().
82 """
82 """
83 pass
83 pass
84
84
85 def launch(self, buffer_output=False, capture_output=False):
85 def launch(self, buffer_output=False, capture_output=False):
86 # print('*** ENV:', self.env) # dbg
86 # print('*** ENV:', self.env) # dbg
87 # print('*** CMD:', self.cmd) # dbg
87 # print('*** CMD:', self.cmd) # dbg
88 env = os.environ.copy()
88 env = os.environ.copy()
89 env.update(self.env)
89 env.update(self.env)
90 if buffer_output:
90 if buffer_output:
91 capture_output = True
91 capture_output = True
92 self.stdout_capturer = c = StreamCapturer(echo=not buffer_output)
92 self.stdout_capturer = c = StreamCapturer(echo=not buffer_output)
93 c.start()
93 c.start()
94 stdout = c.writefd if capture_output else None
94 stdout = c.writefd if capture_output else None
95 stderr = subprocess.STDOUT if capture_output else None
95 stderr = subprocess.STDOUT if capture_output else None
96 self.process = subprocess.Popen(self.cmd, stdout=stdout,
96 self.process = subprocess.Popen(self.cmd, stdout=stdout,
97 stderr=stderr, env=env)
97 stderr=stderr, env=env)
98
98
99 def wait(self):
99 def wait(self):
100 self.process.wait()
100 self.process.wait()
101 self.stdout_capturer.halt()
101 self.stdout_capturer.halt()
102 self.stdout = self.stdout_capturer.get_buffer()
102 self.stdout = self.stdout_capturer.get_buffer()
103 return self.process.returncode
103 return self.process.returncode
104
104
105 def print_extra_info(self):
105 def print_extra_info(self):
106 """Print extra information about this test run.
106 """Print extra information about this test run.
107
107
108 If we're running in parallel and showing the concise view, this is only
108 If we're running in parallel and showing the concise view, this is only
109 called if the test group fails. Otherwise, it's called before the test
109 called if the test group fails. Otherwise, it's called before the test
110 group is started.
110 group is started.
111
111
112 The base implementation does nothing, but it can be overridden by
112 The base implementation does nothing, but it can be overridden by
113 subclasses.
113 subclasses.
114 """
114 """
115 return
115 return
116
116
117 def cleanup_process(self):
117 def cleanup_process(self):
118 """Cleanup on exit by killing any leftover processes."""
118 """Cleanup on exit by killing any leftover processes."""
119 subp = self.process
119 subp = self.process
120 if subp is None or (subp.poll() is not None):
120 if subp is None or (subp.poll() is not None):
121 return # Process doesn't exist, or is already dead.
121 return # Process doesn't exist, or is already dead.
122
122
123 try:
123 try:
124 print('Cleaning up stale PID: %d' % subp.pid)
124 print('Cleaning up stale PID: %d' % subp.pid)
125 subp.kill()
125 subp.kill()
126 except: # (OSError, WindowsError) ?
126 except: # (OSError, WindowsError) ?
127 # This is just a best effort, if we fail or the process was
127 # This is just a best effort, if we fail or the process was
128 # really gone, ignore it.
128 # really gone, ignore it.
129 pass
129 pass
130 else:
130 else:
131 for i in range(10):
131 for i in range(10):
132 if subp.poll() is None:
132 if subp.poll() is None:
133 time.sleep(0.1)
133 time.sleep(0.1)
134 else:
134 else:
135 break
135 break
136
136
137 if subp.poll() is None:
137 if subp.poll() is None:
138 # The process did not die...
138 # The process did not die...
139 print('... failed. Manual cleanup may be required.')
139 print('... failed. Manual cleanup may be required.')
140
140
141 def cleanup(self):
141 def cleanup(self):
142 "Kill process if it's still alive, and clean up temporary directories"
142 "Kill process if it's still alive, and clean up temporary directories"
143 self.cleanup_process()
143 self.cleanup_process()
144 for td in self.dirs:
144 for td in self.dirs:
145 td.cleanup()
145 td.cleanup()
146
146
147 __del__ = cleanup
147 __del__ = cleanup
148
148
149
149
150 class PyTestController(TestController):
150 class PyTestController(TestController):
151 """Run Python tests using IPython.testing.iptest"""
151 """Run Python tests using IPython.testing.iptest"""
152 #: str, Python command to execute in subprocess
152 #: str, Python command to execute in subprocess
153 pycmd = None
153 pycmd = None
154
154
155 def __init__(self, section, options):
155 def __init__(self, section, options):
156 """Create new test runner."""
156 """Create new test runner."""
157 TestController.__init__(self)
157 TestController.__init__(self)
158 self.section = section
158 self.section = section
159 # pycmd is put into cmd[2] in PyTestController.launch()
159 # pycmd is put into cmd[2] in PyTestController.launch()
160 self.cmd = [sys.executable, '-c', None, section]
160 self.cmd = [sys.executable, '-c', None, section]
161 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
161 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
162 self.options = options
162 self.options = options
163
163
164 def setup(self):
164 def setup(self):
165 ipydir = TemporaryDirectory()
165 ipydir = TemporaryDirectory()
166 self.dirs.append(ipydir)
166 self.dirs.append(ipydir)
167 self.env['IPYTHONDIR'] = ipydir.name
167 self.env['IPYTHONDIR'] = ipydir.name
168 self.workingdir = workingdir = TemporaryDirectory()
168 self.workingdir = workingdir = TemporaryDirectory()
169 self.dirs.append(workingdir)
169 self.dirs.append(workingdir)
170 self.env['IPTEST_WORKING_DIR'] = workingdir.name
170 self.env['IPTEST_WORKING_DIR'] = workingdir.name
171 # This means we won't get odd effects from our own matplotlib config
171 # This means we won't get odd effects from our own matplotlib config
172 self.env['MPLCONFIGDIR'] = workingdir.name
172 self.env['MPLCONFIGDIR'] = workingdir.name
173 # For security reasons (http://bugs.python.org/issue16202), use
173 # For security reasons (http://bugs.python.org/issue16202), use
174 # a temporary directory to which other users have no access.
174 # a temporary directory to which other users have no access.
175 self.env['TMPDIR'] = workingdir.name
175 self.env['TMPDIR'] = workingdir.name
176
176
177 # Add a non-accessible directory to PATH (see gh-7053)
177 # Add a non-accessible directory to PATH (see gh-7053)
178 noaccess = os.path.join(self.workingdir.name, "_no_access_")
178 noaccess = os.path.join(self.workingdir.name, "_no_access_")
179 self.noaccess = noaccess
179 self.noaccess = noaccess
180 os.mkdir(noaccess, 0)
180 os.mkdir(noaccess, 0)
181
181
182 PATH = os.environ.get('PATH', '')
182 PATH = os.environ.get('PATH', '')
183 if PATH:
183 if PATH:
184 PATH = noaccess + os.pathsep + PATH
184 PATH = noaccess + os.pathsep + PATH
185 else:
185 else:
186 PATH = noaccess
186 PATH = noaccess
187 self.env['PATH'] = PATH
187 self.env['PATH'] = PATH
188
188
189 # From options:
189 # From options:
190 if self.options.xunit:
190 if self.options.xunit:
191 self.add_xunit()
191 self.add_xunit()
192 if self.options.coverage:
192 if self.options.coverage:
193 self.add_coverage()
193 self.add_coverage()
194 self.env['IPTEST_SUBPROC_STREAMS'] = self.options.subproc_streams
194 self.env['IPTEST_SUBPROC_STREAMS'] = self.options.subproc_streams
195 self.cmd.extend(self.options.extra_args)
195 self.cmd.extend(self.options.extra_args)
196
196
197 def cleanup(self):
197 def cleanup(self):
198 """
198 """
199 Make the non-accessible directory created in setup() accessible
199 Make the non-accessible directory created in setup() accessible
200 again, otherwise deleting the workingdir will fail.
200 again, otherwise deleting the workingdir will fail.
201 """
201 """
202 os.chmod(self.noaccess, stat.S_IRWXU)
202 os.chmod(self.noaccess, stat.S_IRWXU)
203 TestController.cleanup(self)
203 TestController.cleanup(self)
204
204
205 @property
205 @property
206 def will_run(self):
206 def will_run(self):
207 try:
207 try:
208 return test_sections[self.section].will_run
208 return test_sections[self.section].will_run
209 except KeyError:
209 except KeyError:
210 return True
210 return True
211
211
212 def add_xunit(self):
212 def add_xunit(self):
213 xunit_file = os.path.abspath(self.section + '.xunit.xml')
213 xunit_file = os.path.abspath(self.section + '.xunit.xml')
214 self.cmd.extend(['--with-xunit', '--xunit-file', xunit_file])
214 self.cmd.extend(['--with-xunit', '--xunit-file', xunit_file])
215
215
216 def add_coverage(self):
216 def add_coverage(self):
217 try:
217 try:
218 sources = test_sections[self.section].includes
218 sources = test_sections[self.section].includes
219 except KeyError:
219 except KeyError:
220 sources = ['IPython']
220 sources = ['IPython']
221
221
222 coverage_rc = ("[run]\n"
222 coverage_rc = ("[run]\n"
223 "data_file = {data_file}\n"
223 "data_file = {data_file}\n"
224 "source =\n"
224 "source =\n"
225 " {source}\n"
225 " {source}\n"
226 ).format(data_file=os.path.abspath('.coverage.'+self.section),
226 ).format(data_file=os.path.abspath('.coverage.'+self.section),
227 source="\n ".join(sources))
227 source="\n ".join(sources))
228 config_file = os.path.join(self.workingdir.name, '.coveragerc')
228 config_file = os.path.join(self.workingdir.name, '.coveragerc')
229 with open(config_file, 'w') as f:
229 with open(config_file, 'w') as f:
230 f.write(coverage_rc)
230 f.write(coverage_rc)
231
231
232 self.env['COVERAGE_PROCESS_START'] = config_file
232 self.env['COVERAGE_PROCESS_START'] = config_file
233 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
233 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
234
234
235 def launch(self, buffer_output=False):
235 def launch(self, buffer_output=False):
236 self.cmd[2] = self.pycmd
236 self.cmd[2] = self.pycmd
237 super(PyTestController, self).launch(buffer_output=buffer_output)
237 super(PyTestController, self).launch(buffer_output=buffer_output)
238
238
239
239
240 js_prefix = 'js/'
240 js_prefix = 'js/'
241
241
242 def get_js_test_dir():
242 def get_js_test_dir():
243 import IPython.html.tests as t
243 import IPython.html.tests as t
244 return os.path.join(os.path.dirname(t.__file__), '')
244 return os.path.join(os.path.dirname(t.__file__), '')
245
245
246 def all_js_groups():
246 def all_js_groups():
247 import glob
247 import glob
248 test_dir = get_js_test_dir()
248 test_dir = get_js_test_dir()
249 all_subdirs = glob.glob(test_dir + '[!_]*/')
249 all_subdirs = glob.glob(test_dir + '[!_]*/')
250 return [js_prefix+os.path.relpath(x, test_dir) for x in all_subdirs]
250 return [js_prefix+os.path.relpath(x, test_dir) for x in all_subdirs]
251
251
252 class JSController(TestController):
252 class JSController(TestController):
253 """Run CasperJS tests """
253 """Run CasperJS tests """
254
254
255 requirements = ['zmq', 'tornado', 'jinja2', 'casperjs', 'sqlite3',
255 requirements = ['zmq', 'tornado', 'jinja2', 'casperjs', 'sqlite3',
256 'jsonschema']
256 'jsonschema']
257
257
258 def __init__(self, section, xunit=True, engine='phantomjs', url=None):
258 def __init__(self, section, xunit=True, engine='phantomjs', url=None):
259 """Create new test runner."""
259 """Create new test runner."""
260 TestController.__init__(self)
260 TestController.__init__(self)
261 self.engine = engine
261 self.engine = engine
262 self.section = section
262 self.section = section
263 self.xunit = xunit
263 self.xunit = xunit
264 self.url = url
264 self.url = url
265 self.slimer_failure = re.compile('^FAIL.*', flags=re.MULTILINE)
265 self.slimer_failure = re.compile('^FAIL.*', flags=re.MULTILINE)
266 js_test_dir = get_js_test_dir()
266 js_test_dir = get_js_test_dir()
267 includes = '--includes=' + os.path.join(js_test_dir,'util.js')
267 includes = '--includes=' + os.path.join(js_test_dir,'util.js')
268 test_cases = os.path.join(js_test_dir, self.section[len(js_prefix):])
268 test_cases = os.path.join(js_test_dir, self.section[len(js_prefix):])
269 self.cmd = ['casperjs', 'test', includes, test_cases, '--engine=%s' % self.engine]
269 self.cmd = ['casperjs', 'test', includes, test_cases, '--engine=%s' % self.engine]
270
270
271 def setup(self):
271 def setup(self):
272 self.ipydir = TemporaryDirectory()
272 self.ipydir = TemporaryDirectory()
273 self.nbdir = TemporaryDirectory()
273 self.nbdir = TemporaryDirectory()
274 self.dirs.append(self.ipydir)
274 self.dirs.append(self.ipydir)
275 self.dirs.append(self.nbdir)
275 self.dirs.append(self.nbdir)
276 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir1', u'sub βˆ‚ir 1a')))
276 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir1', u'sub βˆ‚ir 1a')))
277 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir2', u'sub βˆ‚ir 1b')))
277 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir2', u'sub βˆ‚ir 1b')))
278
278
279 if self.xunit:
279 if self.xunit:
280 self.add_xunit()
280 self.add_xunit()
281
281
282 # If a url was specified, use that for the testing.
282 # If a url was specified, use that for the testing.
283 if self.url:
283 if self.url:
284 try:
284 try:
285 alive = requests.get(self.url).status_code == 200
285 alive = requests.get(self.url).status_code == 200
286 except:
286 except:
287 alive = False
287 alive = False
288
288
289 if alive:
289 if alive:
290 self.cmd.append("--url=%s" % self.url)
290 self.cmd.append("--url=%s" % self.url)
291 else:
291 else:
292 raise Exception('Could not reach "%s".' % self.url)
292 raise Exception('Could not reach "%s".' % self.url)
293 else:
293 else:
294 # start the ipython notebook, so we get the port number
294 # start the ipython notebook, so we get the port number
295 self.server_port = 0
295 self.server_port = 0
296 self._init_server()
296 self._init_server()
297 if self.server_port:
297 if self.server_port:
298 self.cmd.append("--port=%i" % self.server_port)
298 self.cmd.append("--port=%i" % self.server_port)
299 else:
299 else:
300 # don't launch tests if the server didn't start
300 # don't launch tests if the server didn't start
301 self.cmd = [sys.executable, '-c', 'raise SystemExit(1)']
301 self.cmd = [sys.executable, '-c', 'raise SystemExit(1)']
302
302
303 def add_xunit(self):
303 def add_xunit(self):
304 xunit_file = os.path.abspath(self.section.replace('/','.') + '.xunit.xml')
304 xunit_file = os.path.abspath(self.section.replace('/','.') + '.xunit.xml')
305 self.cmd.append('--xunit=%s' % xunit_file)
305 self.cmd.append('--xunit=%s' % xunit_file)
306
306
307 def launch(self, buffer_output):
307 def launch(self, buffer_output):
308 # If the engine is SlimerJS, we need to buffer the output because
308 # If the engine is SlimerJS, we need to buffer the output because
309 # SlimerJS does not support exit codes, so CasperJS always returns 0.
309 # SlimerJS does not support exit codes, so CasperJS always returns 0.
310 if self.engine == 'slimerjs' and not buffer_output:
310 if self.engine == 'slimerjs' and not buffer_output:
311 return super(JSController, self).launch(capture_output=True)
311 return super(JSController, self).launch(capture_output=True)
312
312
313 else:
313 else:
314 return super(JSController, self).launch(buffer_output=buffer_output)
314 return super(JSController, self).launch(buffer_output=buffer_output)
315
315
316 def wait(self, *pargs, **kwargs):
316 def wait(self, *pargs, **kwargs):
317 """Wait for the JSController to finish"""
317 """Wait for the JSController to finish"""
318 ret = super(JSController, self).wait(*pargs, **kwargs)
318 ret = super(JSController, self).wait(*pargs, **kwargs)
319 # If this is a SlimerJS controller, check the captured stdout for
319 # If this is a SlimerJS controller, check the captured stdout for
320 # errors. Otherwise, just return the return code.
320 # errors. Otherwise, just return the return code.
321 if self.engine == 'slimerjs':
321 if self.engine == 'slimerjs':
322 stdout = bytes_to_str(self.stdout)
322 stdout = bytes_to_str(self.stdout)
323 if ret != 0:
323 if ret != 0:
324 # This could still happen e.g. if it's stopped by SIGINT
324 # This could still happen e.g. if it's stopped by SIGINT
325 return ret
325 return ret
326 return bool(self.slimer_failure.search(strip_ansi(stdout)))
326 return bool(self.slimer_failure.search(strip_ansi(stdout)))
327 else:
327 else:
328 return ret
328 return ret
329
329
330 def print_extra_info(self):
330 def print_extra_info(self):
331 print("Running tests with notebook directory %r" % self.nbdir.name)
331 print("Running tests with notebook directory %r" % self.nbdir.name)
332
332
333 @property
333 @property
334 def will_run(self):
334 def will_run(self):
335 should_run = all(have[a] for a in self.requirements + [self.engine])
335 should_run = all(have[a] for a in self.requirements + [self.engine])
336 return should_run
336 return should_run
337
337
338 def _init_server(self):
338 def _init_server(self):
339 "Start the notebook server in a separate process"
339 "Start the notebook server in a separate process"
340 self.server_command = command = [sys.executable,
340 self.server_command = command = [sys.executable,
341 '-m', 'IPython.html',
341 '-m', 'IPython.html',
342 '--no-browser',
342 '--no-browser',
343 '--ipython-dir', self.ipydir.name,
343 '--ipython-dir', self.ipydir.name,
344 '--notebook-dir', self.nbdir.name,
344 '--notebook-dir', self.nbdir.name,
345 ]
345 ]
346 # ipc doesn't work on Windows, and darwin has crazy-long temp paths,
346 # ipc doesn't work on Windows, and darwin has crazy-long temp paths,
347 # which run afoul of ipc's maximum path length.
347 # which run afoul of ipc's maximum path length.
348 if sys.platform.startswith('linux'):
348 if sys.platform.startswith('linux'):
349 command.append('--KernelManager.transport=ipc')
349 command.append('--KernelManager.transport=ipc')
350 self.stream_capturer = c = StreamCapturer()
350 self.stream_capturer = c = StreamCapturer()
351 c.start()
351 c.start()
352 env = os.environ.copy()
352 env = os.environ.copy()
353 if self.engine == 'phantomjs':
353 if self.engine == 'phantomjs':
354 env['IPYTHON_ALLOW_DRAFT_WEBSOCKETS_FOR_PHANTOMJS'] = '1'
354 env['IPYTHON_ALLOW_DRAFT_WEBSOCKETS_FOR_PHANTOMJS'] = '1'
355 self.server = subprocess.Popen(command,
355 self.server = subprocess.Popen(command,
356 stdout=c.writefd,
356 stdout=c.writefd,
357 stderr=subprocess.STDOUT,
357 stderr=subprocess.STDOUT,
358 cwd=self.nbdir.name,
358 cwd=self.nbdir.name,
359 env=env,
359 env=env,
360 )
360 )
361 self.server_info_file = os.path.join(self.ipydir.name,
361 self.server_info_file = os.path.join(self.ipydir.name,
362 'profile_default', 'security', 'nbserver-%i.json' % self.server.pid
362 'profile_default', 'security', 'nbserver-%i.json' % self.server.pid
363 )
363 )
364 self._wait_for_server()
364 self._wait_for_server()
365
365
366 def _wait_for_server(self):
366 def _wait_for_server(self):
367 """Wait 30 seconds for the notebook server to start"""
367 """Wait 30 seconds for the notebook server to start"""
368 for i in range(300):
368 for i in range(300):
369 if self.server.poll() is not None:
369 if self.server.poll() is not None:
370 return self._failed_to_start()
370 return self._failed_to_start()
371 if os.path.exists(self.server_info_file):
371 if os.path.exists(self.server_info_file):
372 try:
372 try:
373 self._load_server_info()
373 self._load_server_info()
374 except ValueError:
374 except ValueError:
375 # If the server is halfway through writing the file, we may
375 # If the server is halfway through writing the file, we may
376 # get invalid JSON; it should be ready next iteration.
376 # get invalid JSON; it should be ready next iteration.
377 pass
377 pass
378 else:
378 else:
379 return
379 return
380 time.sleep(0.1)
380 time.sleep(0.1)
381 print("Notebook server-info file never arrived: %s" % self.server_info_file,
381 print("Notebook server-info file never arrived: %s" % self.server_info_file,
382 file=sys.stderr
382 file=sys.stderr
383 )
383 )
384
384
385 def _failed_to_start(self):
385 def _failed_to_start(self):
386 """Notebook server exited prematurely"""
386 """Notebook server exited prematurely"""
387 captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace')
387 captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace')
388 print("Notebook failed to start: ", file=sys.stderr)
388 print("Notebook failed to start: ", file=sys.stderr)
389 print(self.server_command)
389 print(self.server_command)
390 print(captured, file=sys.stderr)
390 print(captured, file=sys.stderr)
391
391
392 def _load_server_info(self):
392 def _load_server_info(self):
393 """Notebook server started, load connection info from JSON"""
393 """Notebook server started, load connection info from JSON"""
394 with open(self.server_info_file) as f:
394 with open(self.server_info_file) as f:
395 info = json.load(f)
395 info = json.load(f)
396 self.server_port = info['port']
396 self.server_port = info['port']
397
397
398 def cleanup(self):
398 def cleanup(self):
399 try:
399 try:
400 self.server.terminate()
400 self.server.terminate()
401 except OSError:
401 except OSError:
402 # already dead
402 # already dead
403 pass
403 pass
404 # wait 10s for the server to shutdown
404 # wait 10s for the server to shutdown
405 try:
405 try:
406 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
406 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
407 except TimeoutExpired:
407 except TimeoutExpired:
408 # server didn't terminate, kill it
408 # server didn't terminate, kill it
409 try:
409 try:
410 print("Failed to terminate notebook server, killing it.",
410 print("Failed to terminate notebook server, killing it.",
411 file=sys.stderr
411 file=sys.stderr
412 )
412 )
413 self.server.kill()
413 self.server.kill()
414 except OSError:
414 except OSError:
415 # already dead
415 # already dead
416 pass
416 pass
417 # wait another 10s
417 # wait another 10s
418 try:
418 try:
419 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
419 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
420 except TimeoutExpired:
420 except TimeoutExpired:
421 print("Notebook server still running (%s)" % self.server_info_file,
421 print("Notebook server still running (%s)" % self.server_info_file,
422 file=sys.stderr
422 file=sys.stderr
423 )
423 )
424
424
425 self.stream_capturer.halt()
425 self.stream_capturer.halt()
426 TestController.cleanup(self)
426 TestController.cleanup(self)
427
427
428
428
429 def prepare_controllers(options):
429 def prepare_controllers(options):
430 """Returns two lists of TestController instances, those to run, and those
430 """Returns two lists of TestController instances, those to run, and those
431 not to run."""
431 not to run."""
432 testgroups = options.testgroups
432 testgroups = options.testgroups
433 if testgroups:
433 if testgroups:
434 js_testgroups = [g for g in testgroups if g.startswith(js_prefix)]
434 if 'js' in testgroups:
435 py_testgroups = [g for g in testgroups if g not in js_testgroups]
435 js_testgroups = all_js_groups()
436 else:
437 js_testgroups = [g for g in testgroups if g.startswith(js_prefix)]
438 py_testgroups = [g for g in testgroups if not g.startswith('js')]
436 else:
439 else:
437 py_testgroups = py_test_group_names
440 py_testgroups = py_test_group_names
438 if not options.all:
441 if not options.all:
439 js_testgroups = []
442 js_testgroups = []
440 test_sections['parallel'].enabled = False
443 test_sections['parallel'].enabled = False
441 else:
444 else:
442 js_testgroups = all_js_groups()
445 js_testgroups = all_js_groups()
443
446
444 engine = 'slimerjs' if options.slimerjs else 'phantomjs'
447 engine = 'slimerjs' if options.slimerjs else 'phantomjs'
445 c_js = [JSController(name, xunit=options.xunit, engine=engine, url=options.url) for name in js_testgroups]
448 c_js = [JSController(name, xunit=options.xunit, engine=engine, url=options.url) for name in js_testgroups]
446 c_py = [PyTestController(name, options) for name in py_testgroups]
449 c_py = [PyTestController(name, options) for name in py_testgroups]
447
450
448 controllers = c_py + c_js
451 controllers = c_py + c_js
449 to_run = [c for c in controllers if c.will_run]
452 to_run = [c for c in controllers if c.will_run]
450 not_run = [c for c in controllers if not c.will_run]
453 not_run = [c for c in controllers if not c.will_run]
451 return to_run, not_run
454 return to_run, not_run
452
455
453 def do_run(controller, buffer_output=True):
456 def do_run(controller, buffer_output=True):
454 """Setup and run a test controller.
457 """Setup and run a test controller.
455
458
456 If buffer_output is True, no output is displayed, to avoid it appearing
459 If buffer_output is True, no output is displayed, to avoid it appearing
457 interleaved. In this case, the caller is responsible for displaying test
460 interleaved. In this case, the caller is responsible for displaying test
458 output on failure.
461 output on failure.
459
462
460 Returns
463 Returns
461 -------
464 -------
462 controller : TestController
465 controller : TestController
463 The same controller as passed in, as a convenience for using map() type
466 The same controller as passed in, as a convenience for using map() type
464 APIs.
467 APIs.
465 exitcode : int
468 exitcode : int
466 The exit code of the test subprocess. Non-zero indicates failure.
469 The exit code of the test subprocess. Non-zero indicates failure.
467 """
470 """
468 try:
471 try:
469 try:
472 try:
470 controller.setup()
473 controller.setup()
471 if not buffer_output:
474 if not buffer_output:
472 controller.print_extra_info()
475 controller.print_extra_info()
473 controller.launch(buffer_output=buffer_output)
476 controller.launch(buffer_output=buffer_output)
474 except Exception:
477 except Exception:
475 import traceback
478 import traceback
476 traceback.print_exc()
479 traceback.print_exc()
477 return controller, 1 # signal failure
480 return controller, 1 # signal failure
478
481
479 exitcode = controller.wait()
482 exitcode = controller.wait()
480 return controller, exitcode
483 return controller, exitcode
481
484
482 except KeyboardInterrupt:
485 except KeyboardInterrupt:
483 return controller, -signal.SIGINT
486 return controller, -signal.SIGINT
484 finally:
487 finally:
485 controller.cleanup()
488 controller.cleanup()
486
489
487 def report():
490 def report():
488 """Return a string with a summary report of test-related variables."""
491 """Return a string with a summary report of test-related variables."""
489 inf = get_sys_info()
492 inf = get_sys_info()
490 out = []
493 out = []
491 def _add(name, value):
494 def _add(name, value):
492 out.append((name, value))
495 out.append((name, value))
493
496
494 _add('IPython version', inf['ipython_version'])
497 _add('IPython version', inf['ipython_version'])
495 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
498 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
496 _add('IPython package', compress_user(inf['ipython_path']))
499 _add('IPython package', compress_user(inf['ipython_path']))
497 _add('Python version', inf['sys_version'].replace('\n',''))
500 _add('Python version', inf['sys_version'].replace('\n',''))
498 _add('sys.executable', compress_user(inf['sys_executable']))
501 _add('sys.executable', compress_user(inf['sys_executable']))
499 _add('Platform', inf['platform'])
502 _add('Platform', inf['platform'])
500
503
501 width = max(len(n) for (n,v) in out)
504 width = max(len(n) for (n,v) in out)
502 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
505 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
503
506
504 avail = []
507 avail = []
505 not_avail = []
508 not_avail = []
506
509
507 for k, is_avail in have.items():
510 for k, is_avail in have.items():
508 if is_avail:
511 if is_avail:
509 avail.append(k)
512 avail.append(k)
510 else:
513 else:
511 not_avail.append(k)
514 not_avail.append(k)
512
515
513 if avail:
516 if avail:
514 out.append('\nTools and libraries available at test time:\n')
517 out.append('\nTools and libraries available at test time:\n')
515 avail.sort()
518 avail.sort()
516 out.append(' ' + ' '.join(avail)+'\n')
519 out.append(' ' + ' '.join(avail)+'\n')
517
520
518 if not_avail:
521 if not_avail:
519 out.append('\nTools and libraries NOT available at test time:\n')
522 out.append('\nTools and libraries NOT available at test time:\n')
520 not_avail.sort()
523 not_avail.sort()
521 out.append(' ' + ' '.join(not_avail)+'\n')
524 out.append(' ' + ' '.join(not_avail)+'\n')
522
525
523 return ''.join(out)
526 return ''.join(out)
524
527
525 def run_iptestall(options):
528 def run_iptestall(options):
526 """Run the entire IPython test suite by calling nose and trial.
529 """Run the entire IPython test suite by calling nose and trial.
527
530
528 This function constructs :class:`IPTester` instances for all IPython
531 This function constructs :class:`IPTester` instances for all IPython
529 modules and package and then runs each of them. This causes the modules
532 modules and package and then runs each of them. This causes the modules
530 and packages of IPython to be tested each in their own subprocess using
533 and packages of IPython to be tested each in their own subprocess using
531 nose.
534 nose.
532
535
533 Parameters
536 Parameters
534 ----------
537 ----------
535
538
536 All parameters are passed as attributes of the options object.
539 All parameters are passed as attributes of the options object.
537
540
538 testgroups : list of str
541 testgroups : list of str
539 Run only these sections of the test suite. If empty, run all the available
542 Run only these sections of the test suite. If empty, run all the available
540 sections.
543 sections.
541
544
542 fast : int or None
545 fast : int or None
543 Run the test suite in parallel, using n simultaneous processes. If None
546 Run the test suite in parallel, using n simultaneous processes. If None
544 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
547 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
545
548
546 inc_slow : bool
549 inc_slow : bool
547 Include slow tests, like IPython.parallel. By default, these tests aren't
550 Include slow tests, like IPython.parallel. By default, these tests aren't
548 run.
551 run.
549
552
550 slimerjs : bool
553 slimerjs : bool
551 Use slimerjs if it's installed instead of phantomjs for casperjs tests.
554 Use slimerjs if it's installed instead of phantomjs for casperjs tests.
552
555
553 url : unicode
556 url : unicode
554 Address:port to use when running the JS tests.
557 Address:port to use when running the JS tests.
555
558
556 xunit : bool
559 xunit : bool
557 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
560 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
558
561
559 coverage : bool or str
562 coverage : bool or str
560 Measure code coverage from tests. True will store the raw coverage data,
563 Measure code coverage from tests. True will store the raw coverage data,
561 or pass 'html' or 'xml' to get reports.
564 or pass 'html' or 'xml' to get reports.
562
565
563 extra_args : list
566 extra_args : list
564 Extra arguments to pass to the test subprocesses, e.g. '-v'
567 Extra arguments to pass to the test subprocesses, e.g. '-v'
565 """
568 """
566 to_run, not_run = prepare_controllers(options)
569 to_run, not_run = prepare_controllers(options)
567
570
568 def justify(ltext, rtext, width=70, fill='-'):
571 def justify(ltext, rtext, width=70, fill='-'):
569 ltext += ' '
572 ltext += ' '
570 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
573 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
571 return ltext + rtext
574 return ltext + rtext
572
575
573 # Run all test runners, tracking execution time
576 # Run all test runners, tracking execution time
574 failed = []
577 failed = []
575 t_start = time.time()
578 t_start = time.time()
576
579
577 print()
580 print()
578 if options.fast == 1:
581 if options.fast == 1:
579 # This actually means sequential, i.e. with 1 job
582 # This actually means sequential, i.e. with 1 job
580 for controller in to_run:
583 for controller in to_run:
581 print('Test group:', controller.section)
584 print('Test group:', controller.section)
582 sys.stdout.flush() # Show in correct order when output is piped
585 sys.stdout.flush() # Show in correct order when output is piped
583 controller, res = do_run(controller, buffer_output=False)
586 controller, res = do_run(controller, buffer_output=False)
584 if res:
587 if res:
585 failed.append(controller)
588 failed.append(controller)
586 if res == -signal.SIGINT:
589 if res == -signal.SIGINT:
587 print("Interrupted")
590 print("Interrupted")
588 break
591 break
589 print()
592 print()
590
593
591 else:
594 else:
592 # Run tests concurrently
595 # Run tests concurrently
593 try:
596 try:
594 pool = multiprocessing.pool.ThreadPool(options.fast)
597 pool = multiprocessing.pool.ThreadPool(options.fast)
595 for (controller, res) in pool.imap_unordered(do_run, to_run):
598 for (controller, res) in pool.imap_unordered(do_run, to_run):
596 res_string = 'OK' if res == 0 else 'FAILED'
599 res_string = 'OK' if res == 0 else 'FAILED'
597 print(justify('Test group: ' + controller.section, res_string))
600 print(justify('Test group: ' + controller.section, res_string))
598 if res:
601 if res:
599 controller.print_extra_info()
602 controller.print_extra_info()
600 print(bytes_to_str(controller.stdout))
603 print(bytes_to_str(controller.stdout))
601 failed.append(controller)
604 failed.append(controller)
602 if res == -signal.SIGINT:
605 if res == -signal.SIGINT:
603 print("Interrupted")
606 print("Interrupted")
604 break
607 break
605 except KeyboardInterrupt:
608 except KeyboardInterrupt:
606 return
609 return
607
610
608 for controller in not_run:
611 for controller in not_run:
609 print(justify('Test group: ' + controller.section, 'NOT RUN'))
612 print(justify('Test group: ' + controller.section, 'NOT RUN'))
610
613
611 t_end = time.time()
614 t_end = time.time()
612 t_tests = t_end - t_start
615 t_tests = t_end - t_start
613 nrunners = len(to_run)
616 nrunners = len(to_run)
614 nfail = len(failed)
617 nfail = len(failed)
615 # summarize results
618 # summarize results
616 print('_'*70)
619 print('_'*70)
617 print('Test suite completed for system with the following information:')
620 print('Test suite completed for system with the following information:')
618 print(report())
621 print(report())
619 took = "Took %.3fs." % t_tests
622 took = "Took %.3fs." % t_tests
620 print('Status: ', end='')
623 print('Status: ', end='')
621 if not failed:
624 if not failed:
622 print('OK (%d test groups).' % nrunners, took)
625 print('OK (%d test groups).' % nrunners, took)
623 else:
626 else:
624 # If anything went wrong, point out what command to rerun manually to
627 # If anything went wrong, point out what command to rerun manually to
625 # see the actual errors and individual summary
628 # see the actual errors and individual summary
626 failed_sections = [c.section for c in failed]
629 failed_sections = [c.section for c in failed]
627 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
630 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
628 nrunners, ', '.join(failed_sections)), took)
631 nrunners, ', '.join(failed_sections)), took)
629 print()
632 print()
630 print('You may wish to rerun these, with:')
633 print('You may wish to rerun these, with:')
631 print(' iptest', *failed_sections)
634 print(' iptest', *failed_sections)
632 print()
635 print()
633
636
634 if options.coverage:
637 if options.coverage:
635 from coverage import coverage, CoverageException
638 from coverage import coverage, CoverageException
636 cov = coverage(data_file='.coverage')
639 cov = coverage(data_file='.coverage')
637 cov.combine()
640 cov.combine()
638 cov.save()
641 cov.save()
639
642
640 # Coverage HTML report
643 # Coverage HTML report
641 if options.coverage == 'html':
644 if options.coverage == 'html':
642 html_dir = 'ipy_htmlcov'
645 html_dir = 'ipy_htmlcov'
643 shutil.rmtree(html_dir, ignore_errors=True)
646 shutil.rmtree(html_dir, ignore_errors=True)
644 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
647 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
645 sys.stdout.flush()
648 sys.stdout.flush()
646
649
647 # Custom HTML reporter to clean up module names.
650 # Custom HTML reporter to clean up module names.
648 from coverage.html import HtmlReporter
651 from coverage.html import HtmlReporter
649 class CustomHtmlReporter(HtmlReporter):
652 class CustomHtmlReporter(HtmlReporter):
650 def find_code_units(self, morfs):
653 def find_code_units(self, morfs):
651 super(CustomHtmlReporter, self).find_code_units(morfs)
654 super(CustomHtmlReporter, self).find_code_units(morfs)
652 for cu in self.code_units:
655 for cu in self.code_units:
653 nameparts = cu.name.split(os.sep)
656 nameparts = cu.name.split(os.sep)
654 if 'IPython' not in nameparts:
657 if 'IPython' not in nameparts:
655 continue
658 continue
656 ix = nameparts.index('IPython')
659 ix = nameparts.index('IPython')
657 cu.name = '.'.join(nameparts[ix:])
660 cu.name = '.'.join(nameparts[ix:])
658
661
659 # Reimplement the html_report method with our custom reporter
662 # Reimplement the html_report method with our custom reporter
660 cov._harvest_data()
663 cov._harvest_data()
661 cov.config.from_args(omit='*{0}tests{0}*'.format(os.sep), html_dir=html_dir,
664 cov.config.from_args(omit='*{0}tests{0}*'.format(os.sep), html_dir=html_dir,
662 html_title='IPython test coverage',
665 html_title='IPython test coverage',
663 )
666 )
664 reporter = CustomHtmlReporter(cov, cov.config)
667 reporter = CustomHtmlReporter(cov, cov.config)
665 reporter.report(None)
668 reporter.report(None)
666 print('done.')
669 print('done.')
667
670
668 # Coverage XML report
671 # Coverage XML report
669 elif options.coverage == 'xml':
672 elif options.coverage == 'xml':
670 try:
673 try:
671 cov.xml_report(outfile='ipy_coverage.xml')
674 cov.xml_report(outfile='ipy_coverage.xml')
672 except CoverageException as e:
675 except CoverageException as e:
673 print('Generating coverage report failed. Are you running javascript tests only?')
676 print('Generating coverage report failed. Are you running javascript tests only?')
674 import traceback
677 import traceback
675 traceback.print_exc()
678 traceback.print_exc()
676
679
677 if failed:
680 if failed:
678 # Ensure that our exit code indicates failure
681 # Ensure that our exit code indicates failure
679 sys.exit(1)
682 sys.exit(1)
680
683
681 argparser = argparse.ArgumentParser(description='Run IPython test suite')
684 argparser = argparse.ArgumentParser(description='Run IPython test suite')
682 argparser.add_argument('testgroups', nargs='*',
685 argparser.add_argument('testgroups', nargs='*',
683 help='Run specified groups of tests. If omitted, run '
686 help='Run specified groups of tests. If omitted, run '
684 'all tests.')
687 'all tests.')
685 argparser.add_argument('--all', action='store_true',
688 argparser.add_argument('--all', action='store_true',
686 help='Include slow tests not run by default.')
689 help='Include slow tests not run by default.')
687 argparser.add_argument('--slimerjs', action='store_true',
690 argparser.add_argument('--slimerjs', action='store_true',
688 help="Use slimerjs if it's installed instead of phantomjs for casperjs tests.")
691 help="Use slimerjs if it's installed instead of phantomjs for casperjs tests.")
689 argparser.add_argument('--url', help="URL to use for the JS tests.")
692 argparser.add_argument('--url', help="URL to use for the JS tests.")
690 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
693 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
691 help='Run test sections in parallel. This starts as many '
694 help='Run test sections in parallel. This starts as many '
692 'processes as you have cores, or you can specify a number.')
695 'processes as you have cores, or you can specify a number.')
693 argparser.add_argument('--xunit', action='store_true',
696 argparser.add_argument('--xunit', action='store_true',
694 help='Produce Xunit XML results')
697 help='Produce Xunit XML results')
695 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
698 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
696 help="Measure test coverage. Specify 'html' or "
699 help="Measure test coverage. Specify 'html' or "
697 "'xml' to get reports.")
700 "'xml' to get reports.")
698 argparser.add_argument('--subproc-streams', default='capture',
701 argparser.add_argument('--subproc-streams', default='capture',
699 help="What to do with stdout/stderr from subprocesses. "
702 help="What to do with stdout/stderr from subprocesses. "
700 "'capture' (default), 'show' and 'discard' are the options.")
703 "'capture' (default), 'show' and 'discard' are the options.")
701
704
702 def default_options():
705 def default_options():
703 """Get an argparse Namespace object with the default arguments, to pass to
706 """Get an argparse Namespace object with the default arguments, to pass to
704 :func:`run_iptestall`.
707 :func:`run_iptestall`.
705 """
708 """
706 options = argparser.parse_args([])
709 options = argparser.parse_args([])
707 options.extra_args = []
710 options.extra_args = []
708 return options
711 return options
709
712
710 def main():
713 def main():
711 # iptest doesn't work correctly if the working directory is the
714 # iptest doesn't work correctly if the working directory is the
712 # root of the IPython source tree. Tell the user to avoid
715 # root of the IPython source tree. Tell the user to avoid
713 # frustration.
716 # frustration.
714 if os.path.exists(os.path.join(os.getcwd(),
717 if os.path.exists(os.path.join(os.getcwd(),
715 'IPython', 'testing', '__main__.py')):
718 'IPython', 'testing', '__main__.py')):
716 print("Don't run iptest from the IPython source directory",
719 print("Don't run iptest from the IPython source directory",
717 file=sys.stderr)
720 file=sys.stderr)
718 sys.exit(1)
721 sys.exit(1)
719 # Arguments after -- should be passed through to nose. Argparse treats
722 # Arguments after -- should be passed through to nose. Argparse treats
720 # everything after -- as regular positional arguments, so we separate them
723 # everything after -- as regular positional arguments, so we separate them
721 # first.
724 # first.
722 try:
725 try:
723 ix = sys.argv.index('--')
726 ix = sys.argv.index('--')
724 except ValueError:
727 except ValueError:
725 to_parse = sys.argv[1:]
728 to_parse = sys.argv[1:]
726 extra_args = []
729 extra_args = []
727 else:
730 else:
728 to_parse = sys.argv[1:ix]
731 to_parse = sys.argv[1:ix]
729 extra_args = sys.argv[ix+1:]
732 extra_args = sys.argv[ix+1:]
730
733
731 options = argparser.parse_args(to_parse)
734 options = argparser.parse_args(to_parse)
732 options.extra_args = extra_args
735 options.extra_args = extra_args
733
736
734 run_iptestall(options)
737 run_iptestall(options)
735
738
736
739
737 if __name__ == '__main__':
740 if __name__ == '__main__':
738 main()
741 main()
General Comments 0
You need to be logged in to leave comments. Login now