| #!/usr/bin/env python |
| import argparse |
| import difflib |
| import fnmatch |
| import multiprocessing |
| import os |
| import Queue |
| import re |
| import shlex |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import threading |
| import time |
| |
| |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) |
| REPO_ROOT_DIR = os.path.dirname(SCRIPT_DIR) |
| DEFAULT_EXE = os.path.join(REPO_ROOT_DIR, 'scripts', 'run.py') |
| DEFAULT_TIMEOUT = 10 # seconds |
| SLOW_TIMEOUT_MULTIPLIER = 2 |
| |
| |
| class Error(Exception): |
| pass |
| |
| |
| def AsList(value): |
| if value is None: |
| return [] |
| elif type(value) is list: |
| return value |
| else: |
| return [value] |
| |
| |
| def Indent(s, spaces): |
| return ''.join(' '*spaces + l for l in s.splitlines(1)) |
| |
| |
| def DiffLines(expected, actual): |
| expected_lines = expected.splitlines(1) |
| actual_lines = actual.splitlines(1) |
| return list(difflib.unified_diff(expected_lines, actual_lines, |
| fromfile='expected', tofile='actual')) |
| |
| |
| def FindTestFiles(directory, ext, filter_pattern_re): |
| tests = [] |
| for root, dirs, files in os.walk(directory): |
| for f in files: |
| path = os.path.join(root, f) |
| if os.path.splitext(f)[1] == ext: |
| tests.append(os.path.relpath(path, SCRIPT_DIR)) |
| tests.sort() |
| return [test for test in tests if re.match(filter_pattern_re, test)] |
| |
| |
| class TestInfo(object): |
| def __init__(self): |
| self.name = '' |
| self.filename = '' |
| self.header = [] |
| self.input_file = '' |
| self.input_ = [] |
| self.expected_stdout = '' |
| self.expected_stderr = '' |
| self.exe = None |
| self.flags = [] |
| self.expected_error = 0 |
| self.slow = False |
| self.skip = False |
| |
| def Parse(self, filename): |
| self.name = filename |
| self.filename = os.path.join(SCRIPT_DIR, filename) |
| |
| with open(self.filename) as f: |
| seen_keys = set() |
| state = 'header' |
| empty = True |
| header_lines = [] |
| input_lines = [] |
| stdout_lines = [] |
| stderr_lines = [] |
| for line in f.readlines(): |
| empty = False |
| m = re.match(r'\s*/\*\* (STDOUT|STDERR) ///$', line) |
| if m: |
| directive = m.group(1) |
| if directive == 'STDERR': |
| state = 'stderr' |
| continue |
| elif directive == 'STDOUT': |
| state = 'stdout' |
| continue |
| else: |
| m = re.match(r'\s*///(.*)$', line) |
| if m: |
| directive = m.group(1).strip() |
| if state == 'header': |
| key, value = directive.split(':', 1) |
| key = key.strip() |
| value = value.strip() |
| if key in seen_keys: |
| raise Error('%s already set' % key) |
| seen_keys.add(key) |
| if key == 'EXE': |
| self.exe = value |
| elif key == 'STDIN_FILE': |
| self.input_file = value |
| elif key == 'FLAGS': |
| self.flags = shlex.split(value) |
| elif key == 'ERROR': |
| self.expected_error = int(value) |
| elif key == 'SLOW': |
| self.slow = True |
| elif key == 'SKIP': |
| self.skip = True |
| else: |
| raise Error('Unknown directive: %s' % key) |
| elif state in ('stdout', 'stderr'): |
| if not re.match(r'%s \*\*/$' % state.upper(), directive): |
| raise Error('Bad directive in %s block: %s' % ( |
| state, directive)) |
| state = 'none' |
| else: |
| raise Error('Unexpected directive: %s' % directive) |
| elif state == 'header': |
| state = 'input' |
| |
| if state == 'header': |
| header_lines.append(line) |
| if state == 'input': |
| if self.input_file: |
| raise Error('Can\'t have input_file and input') |
| input_lines.append(line) |
| elif state == 'stderr': |
| stderr_lines.append(line) |
| elif state == 'stdout': |
| stdout_lines.append(line) |
| if empty: |
| raise Error('empty test file') |
| |
| self.header = ''.join(header_lines) |
| self.input_ = ''.join(input_lines) |
| self.expected_stdout = ''.join(stdout_lines) |
| self.expected_stderr = ''.join(stderr_lines) |
| |
| def GetExecutable(self, override_exe): |
| # If the test overrides the executable, we assume that it knows best, so we |
| # don't allow the commandline to override it. |
| if self.exe: |
| exe = os.path.join(REPO_ROOT_DIR, self.exe) |
| elif override_exe: |
| exe = override_exe |
| else: |
| exe = DEFAULT_EXE |
| return os.path.abspath(os.path.join(SCRIPT_DIR, exe)) |
| |
| def GetCommand(self, filename, override_exe=None): |
| cmd = [self.GetExecutable(override_exe)] |
| cmd += self.flags |
| cmd += [filename] |
| return cmd |
| |
| def Run(self, timeout, temp_dir, override_exe=None): |
| if self.input_file: |
| file_path = os.path.join(temp_dir, self.input_file) |
| else: |
| file_path = os.path.join(temp_dir, self.name) |
| file_dir = os.path.dirname(file_path) |
| try: |
| os.makedirs(file_dir) |
| except OSError as e: |
| if not os.path.isdir(file_dir): |
| raise |
| |
| if self.slow: |
| timeout *= SLOW_TIMEOUT_MULTIPLIER |
| |
| process = None |
| # Cheesy way to be able to set is_timeout from inside KillProcess |
| is_timeout = [False] |
| def KillProcess(timeout=True): |
| if process: |
| try: |
| os.killpg(os.getpgid(process.pid), 15) |
| except OSError: |
| pass |
| is_timeout[0] = timeout |
| |
| file_ = open(file_path, 'w') |
| try: |
| # add an empty line for each header line so the line numbers match |
| if self.input_file: |
| with open(self.input_file) as input_file: |
| file_.write(input_file.read()) |
| else: |
| file_.write('\n' * self.header.count('\n')) |
| file_.write(self.input_) |
| file_.flush() |
| # make filenames relative to temp_dir so the autogenerated name is not |
| # included in the error output. |
| rel_file_path = os.path.relpath(file_.name, temp_dir) |
| cmd = self.GetCommand(rel_file_path, override_exe) |
| try: |
| start_time = time.time() |
| # http://stackoverflow.com/a/10012262: subprocess with a timeout |
| # http://stackoverflow.com/a/22582602: kill subprocess and children |
| process = subprocess.Popen(cmd, cwd=temp_dir, stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| preexec_fn=os.setsid) |
| timer = threading.Timer(timeout, KillProcess) |
| try: |
| timer.start() |
| stdout, stderr = process.communicate() |
| finally: |
| timer.cancel() |
| if is_timeout[0]: |
| raise Error('TIMEOUT\nSTDOUT:\n%s\nSTDERR:\n%s\n' % (stdout, stderr)) |
| duration = time.time() - start_time |
| except OSError as e: |
| raise Error(str(e)) |
| finally: |
| KillProcess(False) |
| finally: |
| file_.close() |
| |
| return stdout, stderr, process.returncode, duration |
| |
| def Rebase(self, stdout, stderr): |
| with open(self.filename, 'w') as f: |
| f.write(self.header) |
| f.write(self.input_) |
| if stderr: |
| f.write('/** STDERR ///\n') |
| f.write(stderr) |
| f.write('/// STDERR **/\n') |
| if stdout: |
| f.write('/** STDOUT ///\n') |
| f.write(stdout) |
| f.write('/// STDOUT **/\n') |
| |
| def Diff(self, stdout, stderr): |
| msg = '' |
| if self.expected_stderr != stderr: |
| diff_lines = DiffLines(self.expected_stderr, stderr) |
| msg += 'STDERR MISMATCH:\n' + ''.join(diff_lines) |
| |
| if self.expected_stdout != stdout: |
| diff_lines = DiffLines(self.expected_stdout, stdout) |
| msg += 'STDOUT MISMATCH:\n' + ''.join(diff_lines) |
| |
| if msg: |
| raise Error(msg) |
| |
| |
| class Status(object): |
| def __init__(self, verbose): |
| self.verbose = verbose |
| self.start_time = None |
| self.last_length = 0 |
| self.last_finished = None |
| self.passed = 0 |
| self.failed = 0 |
| self.total = 0 |
| self.failed_tests = [] |
| |
| def Start(self, total): |
| self.total = total |
| self.start_time = time.time() |
| |
| def Passed(self, info, duration): |
| self.passed += 1 |
| if self.verbose: |
| sys.stderr.write('+ %s (%.3fs)\n' % (info.name, duration)) |
| else: |
| self.Clear() |
| self._PrintShortStatus(info) |
| sys.stderr.flush() |
| |
| def Failed(self, info, error_msg): |
| self.failed += 1 |
| self.failed_tests.append(info) |
| self.Clear() |
| sys.stderr.write('- %s\n%s\n' % (info.name, Indent(error_msg, 2))) |
| |
| def Skipped(self, info): |
| if self.verbose: |
| sys.stderr.write('. %s (skipped)\n' % info.name) |
| |
| def Timeout(self): |
| self._PrintShortStatus(self.last_finished) |
| |
| def Print(self): |
| self._PrintShortStatus(None) |
| sys.stderr.write('\n') |
| |
| def _PrintShortStatus(self, info): |
| total_duration = time.time() - self.start_time |
| name = info.name if info else '' |
| if self.total: |
| percent = 100 * (self.passed + self.failed) / self.total |
| else: |
| percent = 100 |
| status = '[+%d|-%d|%%%d] (%.2fs) %s\r' % (self.passed, self.failed, |
| percent, total_duration, name) |
| self.last_length = len(status) |
| self.last_finished = info |
| sys.stderr.write(status) |
| |
| def Clear(self): |
| if not self.verbose: |
| sys.stderr.write('%s\r' % (' ' * self.last_length)) |
| |
| |
| def GetAllTestInfo(test_names, status): |
| infos = [] |
| for test_name in test_names: |
| info = TestInfo() |
| try: |
| info.Parse(test_name) |
| infos.append(info) |
| except Error as e: |
| status.Failed(info, str(e)) |
| |
| return infos |
| |
| |
| def main(args): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('-e', '--executable', help='override executable.') |
| parser.add_argument('-v', '--verbose', help='print more diagnotic messages.', |
| action='store_true') |
| parser.add_argument('-l', '--list', help='list all tests.', |
| action='store_true') |
| parser.add_argument('--list-exes', |
| help='list all executables needed for the tests.', |
| action='store_true') |
| parser.add_argument('-r', '--rebase', |
| help='rebase a test to its current output.', |
| action='store_true') |
| parser.add_argument('-j', '--jobs', help='number of jobs to use to run tests', |
| type=int, default=multiprocessing.cpu_count()) |
| parser.add_argument('-t', '--timeout', type=float, default=DEFAULT_TIMEOUT, |
| help='per test timeout in seconds') |
| parser.add_argument('patterns', metavar='pattern', nargs='*', |
| help='test patterns.') |
| options = parser.parse_args(args) |
| |
| if options.patterns: |
| pattern_re = '|'.join(fnmatch.translate('*%s*' % p) |
| for p in options.patterns) |
| else: |
| pattern_re = '.*' |
| |
| test_names = FindTestFiles(SCRIPT_DIR, '.c', pattern_re) |
| if options.list: |
| for test_name in test_names: |
| print test_name |
| return 0 |
| if not test_names: |
| print 'no tests match that filter' |
| return 1 |
| |
| if options.executable: |
| if not os.path.exists(options.executable): |
| parser.error('executable %s does not exist' % options.executable) |
| options.executable = os.path.abspath(options.executable) |
| |
| isatty = os.isatty(1) |
| |
| status = Status(options.verbose) |
| infos = GetAllTestInfo(test_names, status) |
| |
| if options.list_exes: |
| exes = set([info.exe for info in infos]) |
| if None in exes: |
| exes.remove(None) |
| exes.add(os.path.relpath(DEFAULT_EXE, os.getcwd())) |
| print '\n'.join(exes) |
| return 0 |
| |
| inq = multiprocessing.Queue() |
| test_count = 0 |
| for info in infos: |
| if info.skip: |
| status.Skipped(info) |
| continue |
| inq.put(info) |
| test_count += 1 |
| |
| outq = multiprocessing.Queue() |
| num_proc = options.jobs |
| processes = [] |
| status.Start(test_count) |
| |
| def Worker(i, options, inq, outq): |
| try: |
| while True: |
| try: |
| info = inq.get(False) |
| try: |
| out = info.Run(options.timeout, temp_dir, options.executable) |
| except Exception as e: |
| outq.put((info, e)) |
| continue |
| outq.put((info, out)) |
| except Queue.Empty: |
| # Seems this can be fired even when the queue isn't actually empty. |
| # Double-check, via inq.empty() |
| if inq.empty(): |
| break |
| except KeyboardInterrupt: |
| pass |
| |
| temp_dir = tempfile.mkdtemp(prefix='wasm-e2e-') |
| try: |
| for i, p in enumerate(range(num_proc)): |
| proc = multiprocessing.Process(target=Worker, |
| args=(i, options, inq, outq)) |
| processes.append(proc) |
| proc.start() |
| |
| finished_tests = 0 |
| while finished_tests < test_count: |
| try: |
| info, result = outq.get(True, 0.01) |
| except Queue.Empty: |
| status.Timeout() |
| continue |
| |
| finished_tests += 1 |
| try: |
| if isinstance(result, Exception): |
| raise result |
| |
| stdout, stderr, returncode, duration = result |
| if returncode != info.expected_error: |
| # This test has already failed, but diff it anyway. |
| msg = 'expected error code %d, got %d.' % (info.expected_error, |
| returncode) |
| try: |
| info.Diff(stdout, stderr) |
| except Error as e: |
| msg += '\n' + str(e) |
| raise Error(msg) |
| else: |
| if options.rebase: |
| info.Rebase(stdout, stderr) |
| else: |
| info.Diff(stdout, stderr) |
| status.Passed(info, duration) |
| except Exception as e: |
| status.Failed(info, str(e)) |
| except KeyboardInterrupt: |
| for proc in processes: |
| proc.join() |
| finally: |
| for proc in processes: |
| proc.terminate() |
| proc.join() |
| shutil.rmtree(temp_dir) |
| |
| status.Clear() |
| |
| ret = 0 |
| |
| if status.failed: |
| sys.stderr.write('**** FAILED %s\n' % ('*' * (80 - 14))) |
| for info in status.failed_tests: |
| sys.stderr.write('- %s\n' % info.name) |
| ret = 1 |
| |
| status.Print() |
| return ret |
| |
| |
| if __name__ == '__main__': |
| try: |
| sys.exit(main(sys.argv[1:])) |
| except Error as e: |
| sys.stderr.write(str(e) + '\n') |
| sys.exit(1) |