#!/usr/bin/env python3 # encoding: utf-8 """ This script runs a sequence of wml unit test scenarios. """ import argparse, enum, os, re, subprocess, sys class Verbosity(enum.IntEnum): """What to display depending on how many -v arguments were given on the command line.""" # This doesn't use enum.unique, more than one of these could have the same int level NAMES_OF_TESTS_RUN = 1 # Show progress when running interactively, with a new line each time a new batch starts SCRIPT_DEBUGGING = 2 # The command lines needed to run Wesnoth directly for a given test is printed OUTPUT_OF_PASSING_TESTS = 3 # Print the output even when tests give the expected result class UnexpectedTestStatusException(Exception): """Exception raised when a unit test doesn't return the expected result.""" pass class UnitTestResult(enum.Enum): """Enum corresponding to game_launcher.hpp's unit_test_result""" PASS = 0 FAIL = 1 TIMEOUT = 2 FAIL_LOADING_REPLAY = 3 FAIL_PLAYING_REPLAY = 4 FAIL_WML_EXCEPTION = 6 FAIL_BY_DEFEAT = 7 PASS_BY_VICTORY = 8 BROKE_STRICT_TEST_PASS = 9 BROKE_STRICT_TEST_FAIL = 10 BROKE_STRICT_TEST_FAIL_BY_DEFEAT = 11 BROKE_STRICT_TEST_PASS_BY_VICTORY = 12 def __str__(self): return str(self.value) + ' ' + self.name class TestCase: """Represents a single line of the wml_test_schedule.""" def __init__(self, status, name): self.status = status self.name = name def __str__(self): return "TestCase<{status}, {name}>".format(status=self.status, name=self.name) class TestResultAccumulator: passed = [] skipped = [] failed = [] crashed = [] def __init__(self, total): self.total = total def pass_test(self, batch): """These tests had the expected result - passed if UnitTestResult.PASS was expected, etc.""" self.passed += batch def skip_test(self, batch): """These tests were not run at all. For example, if they expect UnitTestResult.TIMEOUT but the run requested no timeouts.""" self.skipped += batch def fail_test(self, batch): """These tests did not crash, but did not have the expected result - they passed if UnitTestResult.FAIL was expected, etc.""" self.failed += batch def crash_test(self, batch): """Segfaults and similar unusual exits from the program under test.""" self.crashed += batch def __bool__(self): """Returns true if everything that was expected to run (based on the command line options) ran and passed.""" # The logic here is redundant, from the length of passed and skipped we should know that failed and crashed are both empty; # however a false everything-passed result is much worse than a couple of extra checks here. return len(self.passed) + len(self.skipped) == self.total and len(self.failed) == 0 and len(self.crashed) == 0 def __str__(self): result = "Passed {count} of {total} tests\n".format(count=len(self.passed), total=self.total) if self.skipped: result += "Skipped batches containing {count} tests: ({names})\n".format(count=len(self.skipped), names=", ".join([test.name for test in self.skipped])) if self.failed: result += "Failed batches containing {count} tests: ({names})\n".format(count=len(self.failed), names=", ".join([test.name for test in self.failed])) if self.crashed: result += "Crashed during batches containing {count} tests: ({names})".format(count=len(self.crashed), names=", ".join([test.name for test in self.crashed])) return result; class TestListParser: """Each line in the list of tests should be formatted: For example: 0 test_functionality Lines beginning # are treated as comments. """ def __init__(self, options): self.verbose = options.verbose self.filename = options.list if options.filter is not None: self.filter = re.compile(options.filter) else: self.filter = None def get(self, batcher): status_name_re = re.compile(r"^(\d+) ([\w-]+)$") test_list = [] for line in open(self.filename, mode="rt"): line = line.strip() if line == "" or line.startswith("#"): continue x = status_name_re.match(line) if x is None: print("Could not parse test list file: ", line) if self.filter and not self.filter.search(line): continue t = TestCase(UnitTestResult(int(x.groups()[0])), x.groups()[1]) if self.verbose >= Verbosity.SCRIPT_DEBUGGING: print(t) test_list.append(t) return batcher(test_list), TestResultAccumulator(len(test_list)) def run_with_rerun_for_sdl_video(args, timeout): """A wrapper for subprocess.run with a workaround for the issue of travis+18.04 intermittently failing to initialise SDL. """ # Sanity check on the number of retries. It's a rare failure, a single retry would probably # be enough. sdl_retries = 0 while sdl_retries < 10: res = subprocess.run(args, timeout=timeout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) retry = False if b"Could not initialize SDL_video" in res.stdout: retry = True if not retry: return res sdl_retries += 1 print("Could not initialise SDL_video error, attempt", sdl_retries) class WesnothRunner: def __init__(self, options): self.verbose = options.verbose if options.path in ["XCode", "xcode", "Xcode"]: import glob path_list = [] build = "Debug" if options.debug_bin else "Release" pattern = os.path.join("~/Library/Developer/XCode/DerivedData/The_Battle_for_Wesnoth*/Build/Products", build, "The Battle for Wesnoth.app/Contents/MacOS/The Battle for Wesnoth") path_list.extend(glob.glob(os.path.expanduser(pattern))) if len(path_list) == 0: raise FileNotFoundError("Couldn't find your xcode build dir") if len(path_list) > 1: # seems better than choosing one at random raise RuntimeError("Found more than one xcode build dir") path = path_list[0] else: if options.path is None: path = os.path.split(os.path.realpath(sys.argv[0]))[0] else: path = options.path if os.path.isdir(path): path += "/wesnoth" if options.debug_bin: path += "-debug" self.common_args = [path, "--nobanner"] if options.strict_mode: self.common_args.append("--log-strict=warning") if options.clean: self.common_args.append("--noaddons") if options.additional_arg is not None: self.common_args.extend(options.additional_arg) self.timeout = options.timeout self.batch_timeout = options.batch_timeout if self.verbose >= Verbosity.SCRIPT_DEBUGGING: print("Options that will be used for all Wesnoth instances:", repr(self.common_args)) def run_tests(self, test_list, test_summary): """Run all of the tests in a single instance of Wesnoth""" if len(test_list) == 0: raise ValueError("Running an empty test list") if len(test_list) > 1: for test in test_list: if test.status != UnitTestResult.PASS: raise NotImplementedError("run_tests doesn't yet support batching tests with non-zero statuses") expected_result = test_list[0].status if expected_result == UnitTestResult.TIMEOUT and self.timeout == 0: test_summary.skip_test(test_list) if self.verbose >= Verbosity.NAMES_OF_TESTS_RUN: print('Skipping test', test_list[0].name, 'because timeout is disabled') return if ( expected_result == UnitTestResult.BROKE_STRICT_TEST_PASS or expected_result == UnitTestResult.BROKE_STRICT_TEST_FAIL or expected_result == UnitTestResult.BROKE_STRICT_TEST_FAIL_BY_DEFEAT or expected_result == UnitTestResult.BROKE_STRICT_TEST_PASS_BY_VICTORY ) and not options.strict_mode: test_summary.skip_test(test_list) if self.verbose >= Verbosity.NAMES_OF_TESTS_RUN: print('Skipping test', test_list[0].name, 'because strict mode is disabled') return args = self.common_args.copy() for test in test_list: args.append("-u") args.append(test.name) if self.timeout == 0: timeout = None elif len(test_list) == 1: timeout = self.timeout else: timeout = self.batch_timeout if self.verbose >= Verbosity.NAMES_OF_TESTS_RUN: if len(test_list) == 1: print("Running test", test_list[0].name) else: print("Running {count} tests ({names})".format(count=len(test_list), names=", ".join([test.name for test in test_list]))) if self.verbose >= Verbosity.SCRIPT_DEBUGGING: print(repr(args)) try: res = run_with_rerun_for_sdl_video(args, timeout) except subprocess.TimeoutExpired as t: if expected_result != UnitTestResult.TIMEOUT: print("Timed out (killed by Python timeout implementation), while running batch ({names})".format( names=", ".join([test.name for test in test_list]))) res = subprocess.CompletedProcess(args, UnitTestResult.TIMEOUT.value, t.output or b'') if self.verbose >= Verbosity.OUTPUT_OF_PASSING_TESTS: print(res.stdout.decode('utf-8')) print("Result:", res.returncode) if res.returncode < 0: print("Wesnoth exited because of signal", -res.returncode) if options.backtrace: print("Launching GDB for a backtrace...") gdb_args = ["gdb", "-q", "-batch", "-ex", "set style enabled on", "-ex", "start", "-ex", "continue", "-ex", "bt", "-ex", "quit", "--args"] gdb_args.extend(args) subprocess.run(gdb_args, timeout=240) test_summary.crash_test(test_list) raise UnexpectedTestStatusException() returned_result = None try: returned_result = UnitTestResult(res.returncode) except: print("Wesnoth returned an unexpected value: ", res.returncode) test_summary.crash_test(test_list) raise UnexpectedTestStatusException() if returned_result == expected_result: test_summary.pass_test(test_list) else: if self.verbose < Verbosity.OUTPUT_OF_PASSING_TESTS: # Batch mode only supports tests that should UnitTestResult.PASS, so the output # here is likely to have many 'PASS TEST' lines with the failing test last. # If support for batching other UnitTestResults is added then this needs to filter # the output from the other tests. print(res.stdout.decode('utf-8')) print("Failure, Wesnoth returned", returned_result, "but we expected", expected_result) test_summary.fail_test(test_list) raise UnexpectedTestStatusException() def test_batcher(test_list): """A generator function that collects tests into batches which a single instance of Wesnoth can run. """ expected_to_pass = [] for test in test_list: if test.status == UnitTestResult.PASS: expected_to_pass.append(test) else: yield [test] if len(expected_to_pass) == 0: return if options.batch_max == 0: yield expected_to_pass return while len(expected_to_pass) > 0: yield expected_to_pass[0:options.batch_max] expected_to_pass = expected_to_pass[options.batch_max:] def test_nobatcher(test_list): """A generator function that provides the same API as test_batcher but emits the tests one at a time.""" for test in test_list: yield [test] if __name__ == '__main__': ap = argparse.ArgumentParser() # The options that are mandatory to support (because they're used in the Travis script) # are the one-letter forms of verbose, clean, timeout and backtrace. ap.add_argument("-v", "--verbose", action="count", default=0, help="Verbose mode. Additional -v arguments increase the verbosity.") ap.add_argument("-c", "--clean", action="store_true", help="Clean mode. (Don't load any add-ons. Used for mainline tests.)") ap.add_argument("-a", "--additional_arg", action="append", help="Additional arguments to go to wesnoth. For options that start with a hyphen, '--add_argument --data-dir' will give an error, use '--add_argument=--data-dir' instead.") ap.add_argument("-t", "--timeout", type=int, default=10, help="New timer value to use, instead of 10s as default. The value 0 means no timer, and also skips tests that expect timeout.") ap.add_argument("-bt", "--batch-timeout", type=int, default=300, help="New timer value to use for batched tests, instead of 300s as default.") ap.add_argument("-bm", "--batch-max", type=int, default=0, help="Maximum number of tests to do in a batch. Default no limit.") ap.add_argument("-bd", "--batch-disable", action="store_const", const=1, dest='batch_max', help="Disable test batching, may be useful if debugging a small subset of tests. Equivalent to --batch-max=1") ap.add_argument("-s", "--no-strict", dest="strict_mode", action="store_false", help="Disable strict mode. By default, we run wesnoth with the option --log-strict=warning to ensure errors result in a failed test.") ap.add_argument("-d", "--debug_bin", action="store_true", help="Run wesnoth-debug binary instead of wesnoth.") ap.add_argument("-f", "--filter", help="Run the subset of tests whose name matches the given string. Regex patterns are supported.") ap.add_argument("-g", "--backtrace", action="store_true", help="If we encounter a crash, generate a backtrace using gdb. Must have gdb installed for this option.") ap.add_argument("-p", "--path", metavar="dir", help="Path to wesnoth binary. By default assume it is with this script." + " If running on a Mac, passing 'xcode' as the path will attempt to locate the binary in Xcode derived data directories.") ap.add_argument("-l", "--list", metavar="filename", help="Loads list of tests from the given file.", default="wml_test_schedule") # Workaround for argparse not accepting option values that start with a hyphen, # for example "-a --user-data-dir". https://bugs.python.org/issue9334 # New callers can use "-a=--user-data-dir", but compatibility with the old version # of run_wml_tests needs support for "-a --user-data-dir". try: while True: i = sys.argv.index("-a") sys.argv[i] = "=".join(["-a", sys.argv.pop(i + 1)]) except IndexError: pass except ValueError: pass options = ap.parse_args() if options.verbose > 1: print(repr(options)) batcher = test_nobatcher if options.batch_max == 1 else test_batcher test_list, test_summary = TestListParser(options).get(batcher) runner = WesnothRunner(options) for batch in test_list: try: runner.run_tests(batch, test_summary) except UnexpectedTestStatusException as e: if test_summary: raise RuntimeError("Logic error in run_wml_tests - a test has failed, but test_summary says everything passed") if options.verbose > 0 or not test_summary: print(test_summary) else: print("WML Unit Tests Result:", len(test_summary.passed), "of", test_summary.total, "tests passed") if not test_summary: sys.exit(1)