| # Copyright (c) 2018 Google LLC | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """Manages and runs tests from the current working directory. | |
| This will traverse the current working directory and look for python files that | |
| contain subclasses of SpirvTest. | |
| If a class has an @inside_spirv_testsuite decorator, an instance of that | |
| class will be created and serve as a test case in that testsuite. The test | |
| case is then run by the following steps: | |
| 1. A temporary directory will be created. | |
| 2. The spirv_args member variable will be inspected and all placeholders in it | |
| will be expanded by calling instantiate_for_spirv_args() on placeholders. | |
| The transformed list elements are then supplied as arguments to the spirv-* | |
| tool under test. | |
| 3. If the environment member variable exists, its write() method will be | |
| invoked. | |
| 4. All expected_* member variables will be inspected and all placeholders in | |
| them will be expanded by calling instantiate_for_expectation() on those | |
| placeholders. After placeholder expansion, if the expected_* variable is | |
| a list, its element will be joined together with '' to form a single | |
| string. These expected_* variables are to be used by the check_*() methods. | |
| 5. The spirv-* tool will be run with the arguments supplied in spirv_args. | |
| 6. All check_*() member methods will be called by supplying a TestStatus as | |
| argument. Each check_*() method is expected to return a (Success, Message) | |
| pair where Success is a boolean indicating success and Message is an error | |
| message. | |
| 7. If any check_*() method fails, the error message is output and the | |
| current test case fails. | |
| If --leave-output was not specified, all temporary files and directories will | |
| be deleted. | |
| """ | |
| import argparse | |
| import fnmatch | |
| import inspect | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from collections import defaultdict | |
| from placeholder import PlaceHolder | |
| EXPECTED_BEHAVIOR_PREFIX = 'expected_' | |
| VALIDATE_METHOD_PREFIX = 'check_' | |
| def get_all_variables(instance): | |
| """Returns the names of all the variables in instance.""" | |
| return [v for v in dir(instance) if not callable(getattr(instance, v))] | |
| def get_all_methods(instance): | |
| """Returns the names of all methods in instance.""" | |
| return [m for m in dir(instance) if callable(getattr(instance, m))] | |
| def get_all_superclasses(cls): | |
| """Returns all superclasses of a given class. Omits root 'object' superclass. | |
| Returns: | |
| A list of superclasses of the given class. The order guarantees that | |
| * A Base class precedes its derived classes, e.g., for "class B(A)", it | |
| will be [..., A, B, ...]. | |
| * When there are multiple base classes, base classes declared first | |
| precede those declared later, e.g., for "class C(A, B), it will be | |
| [..., A, B, C, ...] | |
| """ | |
| classes = [] | |
| for superclass in cls.__bases__: | |
| for c in get_all_superclasses(superclass): | |
| if c is not object and c not in classes: | |
| classes.append(c) | |
| for superclass in cls.__bases__: | |
| if superclass is not object and superclass not in classes: | |
| classes.append(superclass) | |
| return classes | |
| def get_all_test_methods(test_class): | |
| """Gets all validation methods. | |
| Returns: | |
| A list of validation methods. The order guarantees that | |
| * A method defined in superclass precedes one defined in subclass, | |
| e.g., for "class A(B)", methods defined in B precedes those defined | |
| in A. | |
| * If a subclass has more than one superclass, e.g., "class C(A, B)", | |
| then methods defined in A precedes those defined in B. | |
| """ | |
| classes = get_all_superclasses(test_class) | |
| classes.append(test_class) | |
| all_tests = [ | |
| m for c in classes for m in get_all_methods(c) | |
| if m.startswith(VALIDATE_METHOD_PREFIX) | |
| ] | |
| unique_tests = [] | |
| for t in all_tests: | |
| if t not in unique_tests: | |
| unique_tests.append(t) | |
| return unique_tests | |
| class SpirvTest: | |
| """Base class for spirv test cases. | |
| Subclasses define test cases' facts (shader source code, spirv command, | |
| result validation), which will be used by the TestCase class for running | |
| tests. Subclasses should define spirv_args (specifying spirv_tool command | |
| arguments), and at least one check_*() method (for result validation) for | |
| a full-fledged test case. All check_*() methods should take a TestStatus | |
| parameter and return a (Success, Message) pair, in which Success is a | |
| boolean indicating success and Message is an error message. The test passes | |
| iff all check_*() methods returns true. | |
| Often, a test case class will delegate the check_* behaviors by inheriting | |
| from other classes. | |
| """ | |
| def name(self): | |
| return self.__class__.__name__ | |
| class TestStatus: | |
| """A struct for holding run status of a test case.""" | |
| def __init__(self, test_manager, returncode, stdout, stderr, directory, | |
| inputs, input_filenames): | |
| self.test_manager = test_manager | |
| self.returncode = returncode | |
| # Some of our MacOS bots still run Python 2, so need to be backwards | |
| # compatible here. | |
| if type(stdout) is not str: | |
| if sys.version_info[0] == 2: | |
| self.stdout = stdout.decode('utf-8') | |
| elif sys.version_info[0] == 3: | |
| self.stdout = str(stdout, encoding='utf-8') if stdout is not None else stdout | |
| else: | |
| raise Exception('Unable to determine if running Python 2 or 3 from {}'.format(sys.version_info)) | |
| else: | |
| self.stdout = stdout | |
| if type(stderr) is not str: | |
| if sys.version_info[0] == 2: | |
| self.stderr = stderr.decode('utf-8') | |
| elif sys.version_info[0] == 3: | |
| self.stderr = str(stderr, encoding='utf-8') if stderr is not None else stderr | |
| else: | |
| raise Exception('Unable to determine if running Python 2 or 3 from {}'.format(sys.version_info)) | |
| else: | |
| self.stderr = stderr | |
| # temporary directory where the test runs | |
| self.directory = directory | |
| # List of inputs, as PlaceHolder objects. | |
| self.inputs = inputs | |
| # the names of input shader files (potentially including paths) | |
| self.input_filenames = input_filenames | |
| class SpirvTestException(Exception): | |
| """SpirvTest exception class.""" | |
| pass | |
| def inside_spirv_testsuite(testsuite_name): | |
| """Decorator for subclasses of SpirvTest. | |
| This decorator checks that a class meets the requirements (see below) | |
| for a test case class, and then puts the class in a certain testsuite. | |
| * The class needs to be a subclass of SpirvTest. | |
| * The class needs to have spirv_args defined as a list. | |
| * The class needs to define at least one check_*() methods. | |
| * All expected_* variables required by check_*() methods can only be | |
| of bool, str, or list type. | |
| * Python runtime will throw an exception if the expected_* member | |
| attributes required by check_*() methods are missing. | |
| """ | |
| def actual_decorator(cls): | |
| if not inspect.isclass(cls): | |
| raise SpirvTestException('Test case should be a class') | |
| if not issubclass(cls, SpirvTest): | |
| raise SpirvTestException( | |
| 'All test cases should be subclasses of SpirvTest') | |
| if 'spirv_args' not in get_all_variables(cls): | |
| raise SpirvTestException('No spirv_args found in the test case') | |
| if not isinstance(cls.spirv_args, list): | |
| raise SpirvTestException('spirv_args needs to be a list') | |
| if not any( | |
| [m.startswith(VALIDATE_METHOD_PREFIX) for m in get_all_methods(cls)]): | |
| raise SpirvTestException('No check_*() methods found in the test case') | |
| if not all( | |
| [isinstance(v, (bool, str, list)) for v in get_all_variables(cls)]): | |
| raise SpirvTestException( | |
| 'expected_* variables are only allowed to be bool, str, or ' | |
| 'list type.') | |
| cls.parent_testsuite = testsuite_name | |
| return cls | |
| return actual_decorator | |
| class TestManager: | |
| """Manages and runs a set of tests.""" | |
| def __init__(self, executable_path, assembler_path, disassembler_path): | |
| self.executable_path = executable_path | |
| self.assembler_path = assembler_path | |
| self.disassembler_path = disassembler_path | |
| self.num_successes = 0 | |
| self.num_failures = 0 | |
| self.num_tests = 0 | |
| self.leave_output = False | |
| self.tests = defaultdict(list) | |
| def notify_result(self, test_case, success, message): | |
| """Call this to notify the manager of the results of a test run.""" | |
| self.num_successes += 1 if success else 0 | |
| self.num_failures += 0 if success else 1 | |
| counter_string = str(self.num_successes + self.num_failures) + '/' + str( | |
| self.num_tests) | |
| print('%-10s %-40s ' % (counter_string, test_case.test.name()) + | |
| ('Passed' if success else '-Failed-')) | |
| if not success: | |
| print(' '.join(test_case.command)) | |
| print(message) | |
| def add_test(self, testsuite, test): | |
| """Add this to the current list of test cases.""" | |
| self.tests[testsuite].append(TestCase(test, self)) | |
| self.num_tests += 1 | |
| def run_tests(self): | |
| for suite in self.tests: | |
| print('SPIRV tool test suite: "{suite}"'.format(suite=suite)) | |
| for x in self.tests[suite]: | |
| x.runTest() | |
| class TestCase: | |
| """A single test case that runs in its own directory.""" | |
| def __init__(self, test, test_manager): | |
| self.test = test | |
| self.test_manager = test_manager | |
| self.inputs = [] # inputs, as PlaceHolder objects. | |
| self.file_shaders = [] # filenames of shader files. | |
| self.stdin_shader = None # text to be passed to spirv_tool as stdin | |
| def setUp(self): | |
| """Creates environment and instantiates placeholders for the test case.""" | |
| self.directory = tempfile.mkdtemp(dir=os.getcwd()) | |
| spirv_args = self.test.spirv_args | |
| # Instantiate placeholders in spirv_args | |
| self.test.spirv_args = [ | |
| arg.instantiate_for_spirv_args(self) | |
| if isinstance(arg, PlaceHolder) else arg for arg in self.test.spirv_args | |
| ] | |
| # Get all shader files' names | |
| self.inputs = [arg for arg in spirv_args if isinstance(arg, PlaceHolder)] | |
| self.file_shaders = [arg.filename for arg in self.inputs] | |
| if 'environment' in get_all_variables(self.test): | |
| self.test.environment.write(self.directory) | |
| expectations = [ | |
| v for v in get_all_variables(self.test) | |
| if v.startswith(EXPECTED_BEHAVIOR_PREFIX) | |
| ] | |
| # Instantiate placeholders in expectations | |
| for expectation_name in expectations: | |
| expectation = getattr(self.test, expectation_name) | |
| if isinstance(expectation, list): | |
| expanded_expections = [ | |
| element.instantiate_for_expectation(self) | |
| if isinstance(element, PlaceHolder) else element | |
| for element in expectation | |
| ] | |
| setattr(self.test, expectation_name, expanded_expections) | |
| elif isinstance(expectation, PlaceHolder): | |
| setattr(self.test, expectation_name, | |
| expectation.instantiate_for_expectation(self)) | |
| def tearDown(self): | |
| """Removes the directory if we were not instructed to do otherwise.""" | |
| if not self.test_manager.leave_output: | |
| shutil.rmtree(self.directory) | |
| def runTest(self): | |
| """Sets up and runs a test, reports any failures and then cleans up.""" | |
| self.setUp() | |
| success = False | |
| message = '' | |
| try: | |
| self.command = [self.test_manager.executable_path] | |
| self.command.extend(self.test.spirv_args) | |
| process = subprocess.Popen( | |
| args=self.command, | |
| stdin=subprocess.PIPE, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| cwd=self.directory) | |
| output = process.communicate(self.stdin_shader) | |
| test_status = TestStatus(self.test_manager, process.returncode, output[0], | |
| output[1], self.directory, self.inputs, | |
| self.file_shaders) | |
| run_results = [ | |
| getattr(self.test, test_method)(test_status) | |
| for test_method in get_all_test_methods(self.test.__class__) | |
| ] | |
| success, message = zip(*run_results) | |
| success = all(success) | |
| message = '\n'.join(message) | |
| except Exception as e: | |
| success = False | |
| message = str(e) | |
| self.test_manager.notify_result( | |
| self, success, | |
| message + '\nSTDOUT:\n%s\nSTDERR:\n%s' % (output[0], output[1])) | |
| self.tearDown() | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| 'spirv_tool', | |
| metavar='path/to/spirv_tool', | |
| type=str, | |
| nargs=1, | |
| help='Path to the spirv-* tool under test') | |
| parser.add_argument( | |
| 'spirv_as', | |
| metavar='path/to/spirv-as', | |
| type=str, | |
| nargs=1, | |
| help='Path to spirv-as') | |
| parser.add_argument( | |
| 'spirv_dis', | |
| metavar='path/to/spirv-dis', | |
| type=str, | |
| nargs=1, | |
| help='Path to spirv-dis') | |
| parser.add_argument( | |
| '--leave-output', | |
| action='store_const', | |
| const=1, | |
| help='Do not clean up temporary directories') | |
| parser.add_argument( | |
| '--test-dir', nargs=1, help='Directory to gather the tests from') | |
| args = parser.parse_args() | |
| default_path = sys.path | |
| root_dir = os.getcwd() | |
| if args.test_dir: | |
| root_dir = args.test_dir[0] | |
| manager = TestManager(args.spirv_tool[0], args.spirv_as[0], args.spirv_dis[0]) | |
| if args.leave_output: | |
| manager.leave_output = True | |
| for root, _, filenames in os.walk(root_dir): | |
| for filename in fnmatch.filter(filenames, '*.py'): | |
| if filename.endswith('nosetest.py'): | |
| # Skip nose tests, which are for testing functions of | |
| # the test framework. | |
| continue | |
| sys.path = default_path | |
| sys.path.append(root) | |
| mod = __import__(os.path.splitext(filename)[0]) | |
| for _, obj, in inspect.getmembers(mod): | |
| if inspect.isclass(obj) and hasattr(obj, 'parent_testsuite'): | |
| manager.add_test(obj.parent_testsuite, obj()) | |
| manager.run_tests() | |
| if manager.num_failures > 0: | |
| sys.exit(-1) | |
| if __name__ == '__main__': | |
| main() |