xref: /aosp_15_r20/external/zstd/tests/cli-tests/run.py (revision 01826a4963a0d8a59bc3812d29bdf0fb76416722)
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