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