1*01826a49SYabin Cui#!/usr/bin/env python3 2*01826a49SYabin Cui# ################################################################ 3*01826a49SYabin Cui# Copyright (c) Meta Platforms, Inc. and affiliates. 4*01826a49SYabin Cui# All rights reserved. 5*01826a49SYabin Cui# 6*01826a49SYabin Cui# This source code is licensed under both the BSD-style license (found in the 7*01826a49SYabin Cui# LICENSE file in the root directory of this source tree) and the GPLv2 (found 8*01826a49SYabin Cui# in the COPYING file in the root directory of this source tree). 9*01826a49SYabin Cui# You may select, at your option, one of the above-listed licenses. 10*01826a49SYabin Cui# ########################################################################## 11*01826a49SYabin Cui 12*01826a49SYabin Cuiimport argparse 13*01826a49SYabin Cuiimport contextlib 14*01826a49SYabin Cuiimport copy 15*01826a49SYabin Cuiimport fnmatch 16*01826a49SYabin Cuiimport os 17*01826a49SYabin Cuiimport shutil 18*01826a49SYabin Cuiimport subprocess 19*01826a49SYabin Cuiimport sys 20*01826a49SYabin Cuiimport tempfile 21*01826a49SYabin Cuiimport typing 22*01826a49SYabin Cui 23*01826a49SYabin Cui 24*01826a49SYabin CuiZSTD_SYMLINKS = [ 25*01826a49SYabin Cui "zstd", 26*01826a49SYabin Cui "zstdmt", 27*01826a49SYabin Cui "unzstd", 28*01826a49SYabin Cui "zstdcat", 29*01826a49SYabin Cui "zcat", 30*01826a49SYabin Cui "gzip", 31*01826a49SYabin Cui "gunzip", 32*01826a49SYabin Cui "gzcat", 33*01826a49SYabin Cui "lzma", 34*01826a49SYabin Cui "unlzma", 35*01826a49SYabin Cui "xz", 36*01826a49SYabin Cui "unxz", 37*01826a49SYabin Cui "lz4", 38*01826a49SYabin Cui "unlz4", 39*01826a49SYabin Cui] 40*01826a49SYabin Cui 41*01826a49SYabin Cui 42*01826a49SYabin CuiEXCLUDED_DIRS = { 43*01826a49SYabin Cui "bin", 44*01826a49SYabin Cui "common", 45*01826a49SYabin Cui "scratch", 46*01826a49SYabin Cui} 47*01826a49SYabin Cui 48*01826a49SYabin Cui 49*01826a49SYabin CuiEXCLUDED_BASENAMES = { 50*01826a49SYabin Cui "setup", 51*01826a49SYabin Cui "setup_once", 52*01826a49SYabin Cui "teardown", 53*01826a49SYabin Cui "teardown_once", 54*01826a49SYabin Cui "README.md", 55*01826a49SYabin Cui "run.py", 56*01826a49SYabin Cui ".gitignore", 57*01826a49SYabin Cui} 58*01826a49SYabin Cui 59*01826a49SYabin CuiEXCLUDED_SUFFIXES = [ 60*01826a49SYabin Cui ".exact", 61*01826a49SYabin Cui ".glob", 62*01826a49SYabin Cui ".ignore", 63*01826a49SYabin Cui ".exit", 64*01826a49SYabin Cui] 65*01826a49SYabin Cui 66*01826a49SYabin Cui 67*01826a49SYabin Cuidef exclude_dir(dirname: str) -> bool: 68*01826a49SYabin Cui """ 69*01826a49SYabin Cui Should files under the directory :dirname: be excluded from the test runner? 70*01826a49SYabin Cui """ 71*01826a49SYabin Cui if dirname in EXCLUDED_DIRS: 72*01826a49SYabin Cui return True 73*01826a49SYabin Cui return False 74*01826a49SYabin Cui 75*01826a49SYabin Cui 76*01826a49SYabin Cuidef exclude_file(filename: str) -> bool: 77*01826a49SYabin Cui """Should the file :filename: be excluded from the test runner?""" 78*01826a49SYabin Cui if filename in EXCLUDED_BASENAMES: 79*01826a49SYabin Cui return True 80*01826a49SYabin Cui for suffix in EXCLUDED_SUFFIXES: 81*01826a49SYabin Cui if filename.endswith(suffix): 82*01826a49SYabin Cui return True 83*01826a49SYabin Cui return False 84*01826a49SYabin Cui 85*01826a49SYabin Cuidef read_file(filename: str) -> bytes: 86*01826a49SYabin Cui """Reads the file :filename: and returns the contents as bytes.""" 87*01826a49SYabin Cui with open(filename, "rb") as f: 88*01826a49SYabin Cui return f.read() 89*01826a49SYabin Cui 90*01826a49SYabin Cui 91*01826a49SYabin Cuidef diff(a: bytes, b: bytes) -> str: 92*01826a49SYabin Cui """Returns a diff between two different byte-strings :a: and :b:.""" 93*01826a49SYabin Cui assert a != b 94*01826a49SYabin Cui with tempfile.NamedTemporaryFile("wb") as fa: 95*01826a49SYabin Cui fa.write(a) 96*01826a49SYabin Cui fa.flush() 97*01826a49SYabin Cui with tempfile.NamedTemporaryFile("wb") as fb: 98*01826a49SYabin Cui fb.write(b) 99*01826a49SYabin Cui fb.flush() 100*01826a49SYabin Cui 101*01826a49SYabin Cui diff_bytes = subprocess.run(["diff", fa.name, fb.name], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout 102*01826a49SYabin Cui return diff_bytes.decode("utf8") 103*01826a49SYabin Cui 104*01826a49SYabin Cui 105*01826a49SYabin Cuidef pop_line(data: bytes) -> typing.Tuple[typing.Optional[bytes], bytes]: 106*01826a49SYabin Cui """ 107*01826a49SYabin Cui Pop the first line from :data: and returns the first line and the remainder 108*01826a49SYabin Cui of the data as a tuple. If :data: is empty, returns :(None, data):. Otherwise 109*01826a49SYabin Cui the first line always ends in a :\n:, even if it is the last line and :data: 110*01826a49SYabin Cui doesn't end in :\n:. 111*01826a49SYabin Cui """ 112*01826a49SYabin Cui NEWLINE = b"\n" 113*01826a49SYabin Cui 114*01826a49SYabin Cui if data == b'': 115*01826a49SYabin Cui return (None, data) 116*01826a49SYabin Cui 117*01826a49SYabin Cui parts = data.split(NEWLINE, maxsplit=1) 118*01826a49SYabin Cui line = parts[0] + NEWLINE 119*01826a49SYabin Cui if len(parts) == 1: 120*01826a49SYabin Cui return line, b'' 121*01826a49SYabin Cui 122*01826a49SYabin Cui return line, parts[1] 123*01826a49SYabin Cui 124*01826a49SYabin Cui 125*01826a49SYabin Cuidef glob_line_matches(actual: bytes, expect: bytes) -> bool: 126*01826a49SYabin Cui """ 127*01826a49SYabin Cui Does the `actual` line match the expected glob line `expect`? 128*01826a49SYabin Cui """ 129*01826a49SYabin Cui return fnmatch.fnmatchcase(actual.strip(), expect.strip()) 130*01826a49SYabin Cui 131*01826a49SYabin Cui 132*01826a49SYabin Cuidef glob_diff(actual: bytes, expect: bytes) -> bytes: 133*01826a49SYabin Cui """ 134*01826a49SYabin Cui Returns None if the :actual: content matches the expected glob :expect:, 135*01826a49SYabin Cui otherwise returns the diff bytes. 136*01826a49SYabin Cui """ 137*01826a49SYabin Cui diff = b'' 138*01826a49SYabin Cui actual_line, actual = pop_line(actual) 139*01826a49SYabin Cui expect_line, expect = pop_line(expect) 140*01826a49SYabin Cui while True: 141*01826a49SYabin Cui # Handle end of file conditions - allow extra newlines 142*01826a49SYabin Cui while expect_line is None and actual_line == b"\n": 143*01826a49SYabin Cui actual_line, actual = pop_line(actual) 144*01826a49SYabin Cui while actual_line is None and expect_line == b"\n": 145*01826a49SYabin Cui expect_line, expect = pop_line(expect) 146*01826a49SYabin Cui 147*01826a49SYabin Cui if expect_line is None and actual_line is None: 148*01826a49SYabin Cui if diff == b'': 149*01826a49SYabin Cui return None 150*01826a49SYabin Cui return diff 151*01826a49SYabin Cui elif expect_line is None: 152*01826a49SYabin Cui diff += b"---\n" 153*01826a49SYabin Cui while actual_line != None: 154*01826a49SYabin Cui diff += b"> " 155*01826a49SYabin Cui diff += actual_line 156*01826a49SYabin Cui actual_line, actual = pop_line(actual) 157*01826a49SYabin Cui return diff 158*01826a49SYabin Cui elif actual_line is None: 159*01826a49SYabin Cui diff += b"---\n" 160*01826a49SYabin Cui while expect_line != None: 161*01826a49SYabin Cui diff += b"< " 162*01826a49SYabin Cui diff += expect_line 163*01826a49SYabin Cui expect_line, expect = pop_line(expect) 164*01826a49SYabin Cui return diff 165*01826a49SYabin Cui 166*01826a49SYabin Cui assert expect_line is not None 167*01826a49SYabin Cui assert actual_line is not None 168*01826a49SYabin Cui 169*01826a49SYabin Cui if expect_line == b'...\n': 170*01826a49SYabin Cui next_expect_line, expect = pop_line(expect) 171*01826a49SYabin Cui if next_expect_line is None: 172*01826a49SYabin Cui if diff == b'': 173*01826a49SYabin Cui return None 174*01826a49SYabin Cui return diff 175*01826a49SYabin Cui while not glob_line_matches(actual_line, next_expect_line): 176*01826a49SYabin Cui actual_line, actual = pop_line(actual) 177*01826a49SYabin Cui if actual_line is None: 178*01826a49SYabin Cui diff += b"---\n" 179*01826a49SYabin Cui diff += b"< " 180*01826a49SYabin Cui diff += next_expect_line 181*01826a49SYabin Cui return diff 182*01826a49SYabin Cui expect_line = next_expect_line 183*01826a49SYabin Cui continue 184*01826a49SYabin Cui 185*01826a49SYabin Cui if not glob_line_matches(actual_line, expect_line): 186*01826a49SYabin Cui diff += b'---\n' 187*01826a49SYabin Cui diff += b'< ' + expect_line 188*01826a49SYabin Cui diff += b'> ' + actual_line 189*01826a49SYabin Cui 190*01826a49SYabin Cui actual_line, actual = pop_line(actual) 191*01826a49SYabin Cui expect_line, expect = pop_line(expect) 192*01826a49SYabin Cui 193*01826a49SYabin Cui 194*01826a49SYabin Cuiclass Options: 195*01826a49SYabin Cui """Options configuring how to run a :TestCase:.""" 196*01826a49SYabin Cui def __init__( 197*01826a49SYabin Cui self, 198*01826a49SYabin Cui env: typing.Dict[str, str], 199*01826a49SYabin Cui timeout: typing.Optional[int], 200*01826a49SYabin Cui verbose: bool, 201*01826a49SYabin Cui preserve: bool, 202*01826a49SYabin Cui scratch_dir: str, 203*01826a49SYabin Cui test_dir: str, 204*01826a49SYabin Cui set_exact_output: bool, 205*01826a49SYabin Cui ) -> None: 206*01826a49SYabin Cui self.env = env 207*01826a49SYabin Cui self.timeout = timeout 208*01826a49SYabin Cui self.verbose = verbose 209*01826a49SYabin Cui self.preserve = preserve 210*01826a49SYabin Cui self.scratch_dir = scratch_dir 211*01826a49SYabin Cui self.test_dir = test_dir 212*01826a49SYabin Cui self.set_exact_output = set_exact_output 213*01826a49SYabin Cui 214*01826a49SYabin Cui 215*01826a49SYabin Cuiclass TestCase: 216*01826a49SYabin Cui """ 217*01826a49SYabin Cui Logic and state related to running a single test case. 218*01826a49SYabin Cui 219*01826a49SYabin Cui 1. Initialize the test case. 220*01826a49SYabin Cui 2. Launch the test case with :TestCase.launch():. 221*01826a49SYabin Cui This will start the test execution in a subprocess, but 222*01826a49SYabin Cui not wait for completion. So you could launch multiple test 223*01826a49SYabin Cui cases in parallel. This will now print any test output. 224*01826a49SYabin Cui 3. Analyze the results with :TestCase.analyze():. This will 225*01826a49SYabin Cui join the test subprocess, check the results against the 226*01826a49SYabin Cui expectations, and print the results to stdout. 227*01826a49SYabin Cui 228*01826a49SYabin Cui :TestCase.run(): is also provided which combines the launch & analyze 229*01826a49SYabin Cui steps for single-threaded use-cases. 230*01826a49SYabin Cui 231*01826a49SYabin Cui All other methods, prefixed with _, are private helper functions. 232*01826a49SYabin Cui """ 233*01826a49SYabin Cui def __init__(self, test_filename: str, options: Options) -> None: 234*01826a49SYabin Cui """ 235*01826a49SYabin Cui Initialize the :TestCase: for the test located in :test_filename: 236*01826a49SYabin Cui with the given :options:. 237*01826a49SYabin Cui """ 238*01826a49SYabin Cui self._opts = options 239*01826a49SYabin Cui self._test_file = test_filename 240*01826a49SYabin Cui self._test_name = os.path.normpath( 241*01826a49SYabin Cui os.path.relpath(test_filename, start=self._opts.test_dir) 242*01826a49SYabin Cui ) 243*01826a49SYabin Cui self._success = {} 244*01826a49SYabin Cui self._message = {} 245*01826a49SYabin Cui self._test_stdin = None 246*01826a49SYabin Cui self._scratch_dir = os.path.abspath(os.path.join(self._opts.scratch_dir, self._test_name)) 247*01826a49SYabin Cui 248*01826a49SYabin Cui @property 249*01826a49SYabin Cui def name(self) -> str: 250*01826a49SYabin Cui """Returns the unique name for the test.""" 251*01826a49SYabin Cui return self._test_name 252*01826a49SYabin Cui 253*01826a49SYabin Cui def launch(self) -> None: 254*01826a49SYabin Cui """ 255*01826a49SYabin Cui Launch the test case as a subprocess, but do not block on completion. 256*01826a49SYabin Cui This allows users to run multiple tests in parallel. Results aren't yet 257*01826a49SYabin Cui printed out. 258*01826a49SYabin Cui """ 259*01826a49SYabin Cui self._launch_test() 260*01826a49SYabin Cui 261*01826a49SYabin Cui def analyze(self) -> bool: 262*01826a49SYabin Cui """ 263*01826a49SYabin Cui Must be called after :TestCase.launch():. Joins the test subprocess and 264*01826a49SYabin Cui checks the results against expectations. Finally prints the results to 265*01826a49SYabin Cui stdout and returns the success. 266*01826a49SYabin Cui """ 267*01826a49SYabin Cui self._join_test() 268*01826a49SYabin Cui self._check_exit() 269*01826a49SYabin Cui self._check_stderr() 270*01826a49SYabin Cui self._check_stdout() 271*01826a49SYabin Cui self._analyze_results() 272*01826a49SYabin Cui return self._succeeded 273*01826a49SYabin Cui 274*01826a49SYabin Cui def run(self) -> bool: 275*01826a49SYabin Cui """Shorthand for combining both :TestCase.launch(): and :TestCase.analyze():.""" 276*01826a49SYabin Cui self.launch() 277*01826a49SYabin Cui return self.analyze() 278*01826a49SYabin Cui 279*01826a49SYabin Cui def _log(self, *args, **kwargs) -> None: 280*01826a49SYabin Cui """Logs test output.""" 281*01826a49SYabin Cui print(file=sys.stdout, *args, **kwargs) 282*01826a49SYabin Cui 283*01826a49SYabin Cui def _vlog(self, *args, **kwargs) -> None: 284*01826a49SYabin Cui """Logs verbose test output.""" 285*01826a49SYabin Cui if self._opts.verbose: 286*01826a49SYabin Cui print(file=sys.stdout, *args, **kwargs) 287*01826a49SYabin Cui 288*01826a49SYabin Cui def _test_environment(self) -> typing.Dict[str, str]: 289*01826a49SYabin Cui """ 290*01826a49SYabin Cui Returns the environment to be used for the 291*01826a49SYabin Cui test subprocess. 292*01826a49SYabin Cui """ 293*01826a49SYabin Cui # We want to omit ZSTD cli flags so tests will be consistent across environments 294*01826a49SYabin Cui env = {k: v for k, v in os.environ.items() if not k.startswith("ZSTD")} 295*01826a49SYabin Cui for k, v in self._opts.env.items(): 296*01826a49SYabin Cui self._vlog(f"${k}='{v}'") 297*01826a49SYabin Cui env[k] = v 298*01826a49SYabin Cui return env 299*01826a49SYabin Cui 300*01826a49SYabin Cui def _launch_test(self) -> None: 301*01826a49SYabin Cui """Launch the test subprocess, but do not join it.""" 302*01826a49SYabin Cui args = [os.path.abspath(self._test_file)] 303*01826a49SYabin Cui stdin_name = f"{self._test_file}.stdin" 304*01826a49SYabin Cui if os.path.exists(stdin_name): 305*01826a49SYabin Cui self._test_stdin = open(stdin_name, "rb") 306*01826a49SYabin Cui stdin = self._test_stdin 307*01826a49SYabin Cui else: 308*01826a49SYabin Cui stdin = subprocess.DEVNULL 309*01826a49SYabin Cui cwd = self._scratch_dir 310*01826a49SYabin Cui env = self._test_environment() 311*01826a49SYabin Cui self._test_process = subprocess.Popen( 312*01826a49SYabin Cui args=args, 313*01826a49SYabin Cui stdin=stdin, 314*01826a49SYabin Cui cwd=cwd, 315*01826a49SYabin Cui env=env, 316*01826a49SYabin Cui stderr=subprocess.PIPE, 317*01826a49SYabin Cui stdout=subprocess.PIPE 318*01826a49SYabin Cui ) 319*01826a49SYabin Cui 320*01826a49SYabin Cui def _join_test(self) -> None: 321*01826a49SYabin Cui """Join the test process and save stderr, stdout, and the exit code.""" 322*01826a49SYabin Cui (stdout, stderr) = self._test_process.communicate(timeout=self._opts.timeout) 323*01826a49SYabin Cui self._output = {} 324*01826a49SYabin Cui self._output["stdout"] = stdout 325*01826a49SYabin Cui self._output["stderr"] = stderr 326*01826a49SYabin Cui self._exit_code = self._test_process.returncode 327*01826a49SYabin Cui self._test_process = None 328*01826a49SYabin Cui if self._test_stdin is not None: 329*01826a49SYabin Cui self._test_stdin.close() 330*01826a49SYabin Cui self._test_stdin = None 331*01826a49SYabin Cui 332*01826a49SYabin Cui def _check_output_exact(self, out_name: str, expected: bytes, exact_name: str) -> None: 333*01826a49SYabin Cui """ 334*01826a49SYabin Cui Check the output named :out_name: for an exact match against the :expected: content. 335*01826a49SYabin Cui Saves the success and message. 336*01826a49SYabin Cui """ 337*01826a49SYabin Cui check_name = f"check_{out_name}" 338*01826a49SYabin Cui actual = self._output[out_name] 339*01826a49SYabin Cui if actual == expected: 340*01826a49SYabin Cui self._success[check_name] = True 341*01826a49SYabin Cui self._message[check_name] = f"{out_name} matches!" 342*01826a49SYabin Cui else: 343*01826a49SYabin Cui self._success[check_name] = False 344*01826a49SYabin Cui self._message[check_name] = f"{out_name} does not match!\n> diff expected actual\n{diff(expected, actual)}" 345*01826a49SYabin Cui 346*01826a49SYabin Cui if self._opts.set_exact_output: 347*01826a49SYabin Cui with open(exact_name, "wb") as f: 348*01826a49SYabin Cui f.write(actual) 349*01826a49SYabin Cui 350*01826a49SYabin Cui def _check_output_glob(self, out_name: str, expected: bytes) -> None: 351*01826a49SYabin Cui """ 352*01826a49SYabin Cui Check the output named :out_name: for a glob match against the :expected: glob. 353*01826a49SYabin Cui Saves the success and message. 354*01826a49SYabin Cui """ 355*01826a49SYabin Cui check_name = f"check_{out_name}" 356*01826a49SYabin Cui actual = self._output[out_name] 357*01826a49SYabin Cui diff = glob_diff(actual, expected) 358*01826a49SYabin Cui if diff is None: 359*01826a49SYabin Cui self._success[check_name] = True 360*01826a49SYabin Cui self._message[check_name] = f"{out_name} matches!" 361*01826a49SYabin Cui else: 362*01826a49SYabin Cui utf8_diff = diff.decode('utf8') 363*01826a49SYabin Cui self._success[check_name] = False 364*01826a49SYabin Cui self._message[check_name] = f"{out_name} does not match!\n> diff expected actual\n{utf8_diff}" 365*01826a49SYabin Cui 366*01826a49SYabin Cui def _check_output(self, out_name: str) -> None: 367*01826a49SYabin Cui """ 368*01826a49SYabin Cui Checks the output named :out_name: for a match against the expectation. 369*01826a49SYabin Cui We check for a .exact, .glob, and a .ignore file. If none are found we 370*01826a49SYabin Cui expect that the output should be empty. 371*01826a49SYabin Cui 372*01826a49SYabin Cui If :Options.preserve: was set then we save the scratch directory and 373*01826a49SYabin Cui save the stderr, stdout, and exit code to the scratch directory for 374*01826a49SYabin Cui debugging. 375*01826a49SYabin Cui """ 376*01826a49SYabin Cui if self._opts.preserve: 377*01826a49SYabin Cui # Save the output to the scratch directory 378*01826a49SYabin Cui actual_name = os.path.join(self._scratch_dir, f"{out_name}") 379*01826a49SYabin Cui with open(actual_name, "wb") as f: 380*01826a49SYabin Cui f.write(self._output[out_name]) 381*01826a49SYabin Cui 382*01826a49SYabin Cui exact_name = f"{self._test_file}.{out_name}.exact" 383*01826a49SYabin Cui glob_name = f"{self._test_file}.{out_name}.glob" 384*01826a49SYabin Cui ignore_name = f"{self._test_file}.{out_name}.ignore" 385*01826a49SYabin Cui 386*01826a49SYabin Cui if os.path.exists(exact_name): 387*01826a49SYabin Cui return self._check_output_exact(out_name, read_file(exact_name), exact_name) 388*01826a49SYabin Cui elif os.path.exists(glob_name): 389*01826a49SYabin Cui return self._check_output_glob(out_name, read_file(glob_name)) 390*01826a49SYabin Cui else: 391*01826a49SYabin Cui check_name = f"check_{out_name}" 392*01826a49SYabin Cui self._success[check_name] = True 393*01826a49SYabin Cui self._message[check_name] = f"{out_name} ignored!" 394*01826a49SYabin Cui 395*01826a49SYabin Cui def _check_stderr(self) -> None: 396*01826a49SYabin Cui """Checks the stderr output against the expectation.""" 397*01826a49SYabin Cui self._check_output("stderr") 398*01826a49SYabin Cui 399*01826a49SYabin Cui def _check_stdout(self) -> None: 400*01826a49SYabin Cui """Checks the stdout output against the expectation.""" 401*01826a49SYabin Cui self._check_output("stdout") 402*01826a49SYabin Cui 403*01826a49SYabin Cui def _check_exit(self) -> None: 404*01826a49SYabin Cui """ 405*01826a49SYabin Cui Checks the exit code against expectations. If a .exit file 406*01826a49SYabin Cui exists, we expect that the exit code matches the contents. 407*01826a49SYabin Cui Otherwise we expect the exit code to be zero. 408*01826a49SYabin Cui 409*01826a49SYabin Cui If :Options.preserve: is set we save the exit code to the 410*01826a49SYabin Cui scratch directory under the filename "exit". 411*01826a49SYabin Cui """ 412*01826a49SYabin Cui if self._opts.preserve: 413*01826a49SYabin Cui exit_name = os.path.join(self._scratch_dir, "exit") 414*01826a49SYabin Cui with open(exit_name, "w") as f: 415*01826a49SYabin Cui f.write(str(self._exit_code) + "\n") 416*01826a49SYabin Cui exit_name = f"{self._test_file}.exit" 417*01826a49SYabin Cui if os.path.exists(exit_name): 418*01826a49SYabin Cui exit_code: int = int(read_file(exit_name)) 419*01826a49SYabin Cui else: 420*01826a49SYabin Cui exit_code: int = 0 421*01826a49SYabin Cui if exit_code == self._exit_code: 422*01826a49SYabin Cui self._success["check_exit"] = True 423*01826a49SYabin Cui self._message["check_exit"] = "Exit code matches!" 424*01826a49SYabin Cui else: 425*01826a49SYabin Cui self._success["check_exit"] = False 426*01826a49SYabin Cui self._message["check_exit"] = f"Exit code mismatch! Expected {exit_code} but got {self._exit_code}" 427*01826a49SYabin Cui 428*01826a49SYabin Cui def _analyze_results(self) -> None: 429*01826a49SYabin Cui """ 430*01826a49SYabin Cui After all tests have been checked, collect all the successes 431*01826a49SYabin Cui and messages, and print the results to stdout. 432*01826a49SYabin Cui """ 433*01826a49SYabin Cui STATUS = {True: "PASS", False: "FAIL"} 434*01826a49SYabin Cui checks = sorted(self._success.keys()) 435*01826a49SYabin Cui self._succeeded = all(self._success.values()) 436*01826a49SYabin Cui self._log(f"{STATUS[self._succeeded]}: {self._test_name}") 437*01826a49SYabin Cui 438*01826a49SYabin Cui if not self._succeeded or self._opts.verbose: 439*01826a49SYabin Cui for check in checks: 440*01826a49SYabin Cui if self._opts.verbose or not self._success[check]: 441*01826a49SYabin Cui self._log(f"{STATUS[self._success[check]]}: {self._test_name}.{check}") 442*01826a49SYabin Cui self._log(self._message[check]) 443*01826a49SYabin Cui 444*01826a49SYabin Cui self._log("----------------------------------------") 445*01826a49SYabin Cui 446*01826a49SYabin Cui 447*01826a49SYabin Cuiclass TestSuite: 448*01826a49SYabin Cui """ 449*01826a49SYabin Cui Setup & teardown test suite & cases. 450*01826a49SYabin Cui This class is intended to be used as a context manager. 451*01826a49SYabin Cui 452*01826a49SYabin Cui TODO: Make setup/teardown failure emit messages, not throw exceptions. 453*01826a49SYabin Cui """ 454*01826a49SYabin Cui def __init__(self, test_directory: str, options: Options) -> None: 455*01826a49SYabin Cui self._opts = options 456*01826a49SYabin Cui self._test_dir = os.path.abspath(test_directory) 457*01826a49SYabin Cui rel_test_dir = os.path.relpath(test_directory, start=self._opts.test_dir) 458*01826a49SYabin Cui assert not rel_test_dir.startswith(os.path.sep) 459*01826a49SYabin Cui self._scratch_dir = os.path.normpath(os.path.join(self._opts.scratch_dir, rel_test_dir)) 460*01826a49SYabin Cui 461*01826a49SYabin Cui def __enter__(self) -> 'TestSuite': 462*01826a49SYabin Cui self._setup_once() 463*01826a49SYabin Cui return self 464*01826a49SYabin Cui 465*01826a49SYabin Cui def __exit__(self, _exc_type, _exc_value, _traceback) -> None: 466*01826a49SYabin Cui self._teardown_once() 467*01826a49SYabin Cui 468*01826a49SYabin Cui @contextlib.contextmanager 469*01826a49SYabin Cui def test_case(self, test_basename: str) -> TestCase: 470*01826a49SYabin Cui """ 471*01826a49SYabin Cui Context manager for a test case in the test suite. 472*01826a49SYabin Cui Pass the basename of the test relative to the :test_directory:. 473*01826a49SYabin Cui """ 474*01826a49SYabin Cui assert os.path.dirname(test_basename) == "" 475*01826a49SYabin Cui try: 476*01826a49SYabin Cui self._setup(test_basename) 477*01826a49SYabin Cui test_filename = os.path.join(self._test_dir, test_basename) 478*01826a49SYabin Cui yield TestCase(test_filename, self._opts) 479*01826a49SYabin Cui finally: 480*01826a49SYabin Cui self._teardown(test_basename) 481*01826a49SYabin Cui 482*01826a49SYabin Cui def _remove_scratch_dir(self, dir: str) -> None: 483*01826a49SYabin Cui """Helper to remove a scratch directory with sanity checks""" 484*01826a49SYabin Cui assert "scratch" in dir 485*01826a49SYabin Cui assert dir.startswith(self._scratch_dir) 486*01826a49SYabin Cui assert os.path.exists(dir) 487*01826a49SYabin Cui shutil.rmtree(dir) 488*01826a49SYabin Cui 489*01826a49SYabin Cui def _setup_once(self) -> None: 490*01826a49SYabin Cui if os.path.exists(self._scratch_dir): 491*01826a49SYabin Cui self._remove_scratch_dir(self._scratch_dir) 492*01826a49SYabin Cui os.makedirs(self._scratch_dir) 493*01826a49SYabin Cui setup_script = os.path.join(self._test_dir, "setup_once") 494*01826a49SYabin Cui if os.path.exists(setup_script): 495*01826a49SYabin Cui self._run_script(setup_script, cwd=self._scratch_dir) 496*01826a49SYabin Cui 497*01826a49SYabin Cui def _teardown_once(self) -> None: 498*01826a49SYabin Cui assert os.path.exists(self._scratch_dir) 499*01826a49SYabin Cui teardown_script = os.path.join(self._test_dir, "teardown_once") 500*01826a49SYabin Cui if os.path.exists(teardown_script): 501*01826a49SYabin Cui self._run_script(teardown_script, cwd=self._scratch_dir) 502*01826a49SYabin Cui if not self._opts.preserve: 503*01826a49SYabin Cui self._remove_scratch_dir(self._scratch_dir) 504*01826a49SYabin Cui 505*01826a49SYabin Cui def _setup(self, test_basename: str) -> None: 506*01826a49SYabin Cui test_scratch_dir = os.path.join(self._scratch_dir, test_basename) 507*01826a49SYabin Cui assert not os.path.exists(test_scratch_dir) 508*01826a49SYabin Cui os.makedirs(test_scratch_dir) 509*01826a49SYabin Cui setup_script = os.path.join(self._test_dir, "setup") 510*01826a49SYabin Cui if os.path.exists(setup_script): 511*01826a49SYabin Cui self._run_script(setup_script, cwd=test_scratch_dir) 512*01826a49SYabin Cui 513*01826a49SYabin Cui def _teardown(self, test_basename: str) -> None: 514*01826a49SYabin Cui test_scratch_dir = os.path.join(self._scratch_dir, test_basename) 515*01826a49SYabin Cui assert os.path.exists(test_scratch_dir) 516*01826a49SYabin Cui teardown_script = os.path.join(self._test_dir, "teardown") 517*01826a49SYabin Cui if os.path.exists(teardown_script): 518*01826a49SYabin Cui self._run_script(teardown_script, cwd=test_scratch_dir) 519*01826a49SYabin Cui if not self._opts.preserve: 520*01826a49SYabin Cui self._remove_scratch_dir(test_scratch_dir) 521*01826a49SYabin Cui 522*01826a49SYabin Cui def _run_script(self, script: str, cwd: str) -> None: 523*01826a49SYabin Cui env = copy.copy(os.environ) 524*01826a49SYabin Cui for k, v in self._opts.env.items(): 525*01826a49SYabin Cui env[k] = v 526*01826a49SYabin Cui try: 527*01826a49SYabin Cui subprocess.run( 528*01826a49SYabin Cui args=[script], 529*01826a49SYabin Cui stdin=subprocess.DEVNULL, 530*01826a49SYabin Cui stdout=subprocess.PIPE, 531*01826a49SYabin Cui stderr=subprocess.PIPE, 532*01826a49SYabin Cui cwd=cwd, 533*01826a49SYabin Cui env=env, 534*01826a49SYabin Cui check=True, 535*01826a49SYabin Cui ) 536*01826a49SYabin Cui except subprocess.CalledProcessError as e: 537*01826a49SYabin Cui print(f"{script} failed with exit code {e.returncode}!") 538*01826a49SYabin Cui print(f"stderr:\n{e.stderr}") 539*01826a49SYabin Cui print(f"stdout:\n{e.stdout}") 540*01826a49SYabin Cui raise 541*01826a49SYabin Cui 542*01826a49SYabin CuiTestSuites = typing.Dict[str, typing.List[str]] 543*01826a49SYabin Cui 544*01826a49SYabin Cuidef get_all_tests(options: Options) -> TestSuites: 545*01826a49SYabin Cui """ 546*01826a49SYabin Cui Find all the test in the test directory and return the test suites. 547*01826a49SYabin Cui """ 548*01826a49SYabin Cui test_suites = {} 549*01826a49SYabin Cui for root, dirs, files in os.walk(options.test_dir, topdown=True): 550*01826a49SYabin Cui dirs[:] = [d for d in dirs if not exclude_dir(d)] 551*01826a49SYabin Cui test_cases = [] 552*01826a49SYabin Cui for file in files: 553*01826a49SYabin Cui if not exclude_file(file): 554*01826a49SYabin Cui test_cases.append(file) 555*01826a49SYabin Cui assert root == os.path.normpath(root) 556*01826a49SYabin Cui test_suites[root] = test_cases 557*01826a49SYabin Cui return test_suites 558*01826a49SYabin Cui 559*01826a49SYabin Cui 560*01826a49SYabin Cuidef resolve_listed_tests( 561*01826a49SYabin Cui tests: typing.List[str], options: Options 562*01826a49SYabin Cui) -> TestSuites: 563*01826a49SYabin Cui """ 564*01826a49SYabin Cui Resolve the list of tests passed on the command line into their 565*01826a49SYabin Cui respective test suites. Tests can either be paths, or test names 566*01826a49SYabin Cui relative to the test directory. 567*01826a49SYabin Cui """ 568*01826a49SYabin Cui test_suites = {} 569*01826a49SYabin Cui for test in tests: 570*01826a49SYabin Cui if not os.path.exists(test): 571*01826a49SYabin Cui test = os.path.join(options.test_dir, test) 572*01826a49SYabin Cui if not os.path.exists(test): 573*01826a49SYabin Cui raise RuntimeError(f"Test {test} does not exist!") 574*01826a49SYabin Cui 575*01826a49SYabin Cui test = os.path.normpath(os.path.abspath(test)) 576*01826a49SYabin Cui assert test.startswith(options.test_dir) 577*01826a49SYabin Cui test_suite = os.path.dirname(test) 578*01826a49SYabin Cui test_case = os.path.basename(test) 579*01826a49SYabin Cui test_suites.setdefault(test_suite, []).append(test_case) 580*01826a49SYabin Cui 581*01826a49SYabin Cui return test_suites 582*01826a49SYabin Cui 583*01826a49SYabin Cuidef run_tests(test_suites: TestSuites, options: Options) -> bool: 584*01826a49SYabin Cui """ 585*01826a49SYabin Cui Runs all the test in the :test_suites: with the given :options:. 586*01826a49SYabin Cui Prints the results to stdout. 587*01826a49SYabin Cui """ 588*01826a49SYabin Cui tests = {} 589*01826a49SYabin Cui for test_dir, test_files in test_suites.items(): 590*01826a49SYabin Cui with TestSuite(test_dir, options) as test_suite: 591*01826a49SYabin Cui test_files = sorted(set(test_files)) 592*01826a49SYabin Cui for test_file in test_files: 593*01826a49SYabin Cui with test_suite.test_case(test_file) as test_case: 594*01826a49SYabin Cui tests[test_case.name] = test_case.run() 595*01826a49SYabin Cui 596*01826a49SYabin Cui successes = 0 597*01826a49SYabin Cui for test, status in tests.items(): 598*01826a49SYabin Cui if status: 599*01826a49SYabin Cui successes += 1 600*01826a49SYabin Cui else: 601*01826a49SYabin Cui print(f"FAIL: {test}") 602*01826a49SYabin Cui if successes == len(tests): 603*01826a49SYabin Cui print(f"PASSED all {len(tests)} tests!") 604*01826a49SYabin Cui return True 605*01826a49SYabin Cui else: 606*01826a49SYabin Cui print(f"FAILED {len(tests) - successes} / {len(tests)} tests!") 607*01826a49SYabin Cui return False 608*01826a49SYabin Cui 609*01826a49SYabin Cui 610*01826a49SYabin Cuidef setup_zstd_symlink_dir(zstd_symlink_dir: str, zstd: str) -> None: 611*01826a49SYabin Cui assert os.path.join("bin", "symlinks") in zstd_symlink_dir 612*01826a49SYabin Cui if not os.path.exists(zstd_symlink_dir): 613*01826a49SYabin Cui os.makedirs(zstd_symlink_dir) 614*01826a49SYabin Cui for symlink in ZSTD_SYMLINKS: 615*01826a49SYabin Cui path = os.path.join(zstd_symlink_dir, symlink) 616*01826a49SYabin Cui if os.path.exists(path): 617*01826a49SYabin Cui os.remove(path) 618*01826a49SYabin Cui os.symlink(zstd, path) 619*01826a49SYabin Cui 620*01826a49SYabin Cuiif __name__ == "__main__": 621*01826a49SYabin Cui CLI_TEST_DIR = os.path.dirname(sys.argv[0]) 622*01826a49SYabin Cui REPO_DIR = os.path.join(CLI_TEST_DIR, "..", "..") 623*01826a49SYabin Cui PROGRAMS_DIR = os.path.join(REPO_DIR, "programs") 624*01826a49SYabin Cui TESTS_DIR = os.path.join(REPO_DIR, "tests") 625*01826a49SYabin Cui ZSTD_PATH = os.path.join(PROGRAMS_DIR, "zstd") 626*01826a49SYabin Cui ZSTDGREP_PATH = os.path.join(PROGRAMS_DIR, "zstdgrep") 627*01826a49SYabin Cui ZSTDLESS_PATH = os.path.join(PROGRAMS_DIR, "zstdless") 628*01826a49SYabin Cui DATAGEN_PATH = os.path.join(TESTS_DIR, "datagen") 629*01826a49SYabin Cui 630*01826a49SYabin Cui parser = argparse.ArgumentParser( 631*01826a49SYabin Cui ( 632*01826a49SYabin Cui "Runs the zstd CLI tests. Exits nonzero on failure. Default arguments are\n" 633*01826a49SYabin Cui "generally correct. Pass --preserve to preserve test output for debugging,\n" 634*01826a49SYabin Cui "and --verbose to get verbose test output.\n" 635*01826a49SYabin Cui ) 636*01826a49SYabin Cui ) 637*01826a49SYabin Cui parser.add_argument( 638*01826a49SYabin Cui "--preserve", 639*01826a49SYabin Cui action="store_true", 640*01826a49SYabin Cui help="Preserve the scratch directory TEST_DIR/scratch/ for debugging purposes." 641*01826a49SYabin Cui ) 642*01826a49SYabin Cui parser.add_argument("--verbose", action="store_true", help="Verbose test output.") 643*01826a49SYabin Cui parser.add_argument("--timeout", default=200, type=int, help="Test case timeout in seconds. Set to 0 to disable timeouts.") 644*01826a49SYabin Cui parser.add_argument( 645*01826a49SYabin Cui "--exec-prefix", 646*01826a49SYabin Cui default=None, 647*01826a49SYabin Cui help="Sets the EXEC_PREFIX environment variable. Prefix to invocations of the zstd CLI." 648*01826a49SYabin Cui ) 649*01826a49SYabin Cui parser.add_argument( 650*01826a49SYabin Cui "--zstd", 651*01826a49SYabin Cui default=ZSTD_PATH, 652*01826a49SYabin Cui help="Sets the ZSTD_BIN environment variable. Path of the zstd CLI." 653*01826a49SYabin Cui ) 654*01826a49SYabin Cui parser.add_argument( 655*01826a49SYabin Cui "--zstdgrep", 656*01826a49SYabin Cui default=ZSTDGREP_PATH, 657*01826a49SYabin Cui help="Sets the ZSTDGREP_BIN environment variable. Path of the zstdgrep CLI." 658*01826a49SYabin Cui ) 659*01826a49SYabin Cui parser.add_argument( 660*01826a49SYabin Cui "--zstdless", 661*01826a49SYabin Cui default=ZSTDLESS_PATH, 662*01826a49SYabin Cui help="Sets the ZSTDLESS_BIN environment variable. Path of the zstdless CLI." 663*01826a49SYabin Cui ) 664*01826a49SYabin Cui parser.add_argument( 665*01826a49SYabin Cui "--datagen", 666*01826a49SYabin Cui default=DATAGEN_PATH, 667*01826a49SYabin Cui help="Sets the DATAGEN_BIN environment variable. Path to the datagen CLI." 668*01826a49SYabin Cui ) 669*01826a49SYabin Cui parser.add_argument( 670*01826a49SYabin Cui "--test-dir", 671*01826a49SYabin Cui default=CLI_TEST_DIR, 672*01826a49SYabin Cui help=( 673*01826a49SYabin Cui "Runs the tests under this directory. " 674*01826a49SYabin Cui "Adds TEST_DIR/bin/ to path. " 675*01826a49SYabin Cui "Scratch directory located in TEST_DIR/scratch/." 676*01826a49SYabin Cui ) 677*01826a49SYabin Cui ) 678*01826a49SYabin Cui parser.add_argument( 679*01826a49SYabin Cui "--set-exact-output", 680*01826a49SYabin Cui action="store_true", 681*01826a49SYabin Cui help="Set stderr.exact and stdout.exact for all failing tests, unless .ignore or .glob already exists" 682*01826a49SYabin Cui ) 683*01826a49SYabin Cui parser.add_argument( 684*01826a49SYabin Cui "tests", 685*01826a49SYabin Cui nargs="*", 686*01826a49SYabin Cui help="Run only these test cases. Can either be paths or test names relative to TEST_DIR/" 687*01826a49SYabin Cui ) 688*01826a49SYabin Cui args = parser.parse_args() 689*01826a49SYabin Cui 690*01826a49SYabin Cui if args.timeout <= 0: 691*01826a49SYabin Cui args.timeout = None 692*01826a49SYabin Cui 693*01826a49SYabin Cui args.test_dir = os.path.normpath(os.path.abspath(args.test_dir)) 694*01826a49SYabin Cui bin_dir = os.path.abspath(os.path.join(args.test_dir, "bin")) 695*01826a49SYabin Cui zstd_symlink_dir = os.path.join(bin_dir, "symlinks") 696*01826a49SYabin Cui scratch_dir = os.path.join(args.test_dir, "scratch") 697*01826a49SYabin Cui 698*01826a49SYabin Cui setup_zstd_symlink_dir(zstd_symlink_dir, os.path.abspath(args.zstd)) 699*01826a49SYabin Cui 700*01826a49SYabin Cui env = {} 701*01826a49SYabin Cui if args.exec_prefix is not None: 702*01826a49SYabin Cui env["EXEC_PREFIX"] = args.exec_prefix 703*01826a49SYabin Cui env["ZSTD_SYMLINK_DIR"] = zstd_symlink_dir 704*01826a49SYabin Cui env["ZSTD_REPO_DIR"] = os.path.abspath(REPO_DIR) 705*01826a49SYabin Cui env["DATAGEN_BIN"] = os.path.abspath(args.datagen) 706*01826a49SYabin Cui env["ZSTDGREP_BIN"] = os.path.abspath(args.zstdgrep) 707*01826a49SYabin Cui env["ZSTDLESS_BIN"] = os.path.abspath(args.zstdless) 708*01826a49SYabin Cui env["COMMON"] = os.path.abspath(os.path.join(args.test_dir, "common")) 709*01826a49SYabin Cui env["PATH"] = bin_dir + ":" + os.getenv("PATH", "") 710*01826a49SYabin Cui env["LC_ALL"] = "C" 711*01826a49SYabin Cui 712*01826a49SYabin Cui opts = Options( 713*01826a49SYabin Cui env=env, 714*01826a49SYabin Cui timeout=args.timeout, 715*01826a49SYabin Cui verbose=args.verbose, 716*01826a49SYabin Cui preserve=args.preserve, 717*01826a49SYabin Cui test_dir=args.test_dir, 718*01826a49SYabin Cui scratch_dir=scratch_dir, 719*01826a49SYabin Cui set_exact_output=args.set_exact_output, 720*01826a49SYabin Cui ) 721*01826a49SYabin Cui 722*01826a49SYabin Cui if len(args.tests) == 0: 723*01826a49SYabin Cui tests = get_all_tests(opts) 724*01826a49SYabin Cui else: 725*01826a49SYabin Cui tests = resolve_listed_tests(args.tests, opts) 726*01826a49SYabin Cui 727*01826a49SYabin Cui success = run_tests(tests, opts) 728*01826a49SYabin Cui if success: 729*01826a49SYabin Cui sys.exit(0) 730*01826a49SYabin Cui else: 731*01826a49SYabin Cui sys.exit(1) 732