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