1# Class for actually running tests.
2#
3# Copyright (c) 2020-2021 Virtuozzo International GmbH
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17#
18
19import os
20from pathlib import Path
21import datetime
22import time
23import difflib
24import subprocess
25import contextlib
26import json
27import termios
28import shutil
29import sys
30from multiprocessing import Pool
31from contextlib import contextmanager
32from typing import List, Optional, Iterator, Any, Sequence, Dict, \
33        ContextManager
34
35from testenv import TestEnv
36
37
38def silent_unlink(path: Path) -> None:
39    try:
40        path.unlink()
41    except OSError:
42        pass
43
44
45def file_diff(file1: str, file2: str) -> List[str]:
46    with open(file1, encoding="utf-8") as f1, \
47         open(file2, encoding="utf-8") as f2:
48        # We want to ignore spaces at line ends. There are a lot of mess about
49        # it in iotests.
50        # TODO: fix all tests to not produce extra spaces, fix all .out files
51        # and use strict diff here!
52        seq1 = [line.rstrip() for line in f1]
53        seq2 = [line.rstrip() for line in f2]
54        res = [line.rstrip()
55               for line in difflib.unified_diff(seq1, seq2, file1, file2)]
56        return res
57
58
59# We want to save current tty settings during test run,
60# since an aborting qemu call may leave things screwed up.
61@contextmanager
62def savetty() -> Iterator[None]:
63    isterm = sys.stdin.isatty()
64    if isterm:
65        fd = sys.stdin.fileno()
66        attr = termios.tcgetattr(fd)
67
68    try:
69        yield
70    finally:
71        if isterm:
72            termios.tcsetattr(fd, termios.TCSADRAIN, attr)
73
74
75class LastElapsedTime(ContextManager['LastElapsedTime']):
76    """ Cache for elapsed time for tests, to show it during new test run
77
78    It is safe to use get() at any time.  To use update(), you must either
79    use it inside with-block or use save() after update().
80    """
81    def __init__(self, cache_file: str, env: TestEnv) -> None:
82        self.env = env
83        self.cache_file = cache_file
84        self.cache: Dict[str, Dict[str, Dict[str, float]]]
85
86        try:
87            with open(cache_file, encoding="utf-8") as f:
88                self.cache = json.load(f)
89        except (OSError, ValueError):
90            self.cache = {}
91
92    def get(self, test: str,
93            default: Optional[float] = None) -> Optional[float]:
94        if test not in self.cache:
95            return default
96
97        if self.env.imgproto not in self.cache[test]:
98            return default
99
100        return self.cache[test][self.env.imgproto].get(self.env.imgfmt,
101                                                       default)
102
103    def update(self, test: str, elapsed: float) -> None:
104        d = self.cache.setdefault(test, {})
105        d.setdefault(self.env.imgproto, {})[self.env.imgfmt] = elapsed
106
107    def save(self) -> None:
108        with open(self.cache_file, 'w', encoding="utf-8") as f:
109            json.dump(self.cache, f)
110
111    def __enter__(self) -> 'LastElapsedTime':
112        return self
113
114    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
115        self.save()
116
117
118class TestResult:
119    def __init__(self, status: str, description: str = '',
120                 elapsed: Optional[float] = None, diff: Sequence[str] = (),
121                 casenotrun: str = '', interrupted: bool = False) -> None:
122        self.status = status
123        self.description = description
124        self.elapsed = elapsed
125        self.diff = diff
126        self.casenotrun = casenotrun
127        self.interrupted = interrupted
128
129
130class TestRunner(ContextManager['TestRunner']):
131    shared_self = None
132
133    @staticmethod
134    def proc_run_test(test: str, test_field_width: int) -> TestResult:
135        # We are in a subprocess, we can't change the runner object!
136        runner = TestRunner.shared_self
137        assert runner is not None
138        return runner.run_test(test, test_field_width, mp=True)
139
140    def run_tests_pool(self, tests: List[str],
141                       test_field_width: int, jobs: int) -> List[TestResult]:
142
143        # passing self directly to Pool.starmap() just doesn't work, because
144        # it's a context manager.
145        assert TestRunner.shared_self is None
146        TestRunner.shared_self = self
147
148        with Pool(jobs) as p:
149            results = p.starmap(self.proc_run_test,
150                                zip(tests, [test_field_width] * len(tests)))
151
152        TestRunner.shared_self = None
153
154        return results
155
156    def __init__(self, env: TestEnv, tap: bool = False,
157                 color: str = 'auto') -> None:
158        self.env = env
159        self.tap = tap
160        self.last_elapsed = LastElapsedTime('.last-elapsed-cache', env)
161
162        assert color in ('auto', 'on', 'off')
163        self.color = (color == 'on') or (color == 'auto' and
164                                         sys.stdout.isatty())
165
166        self._stack: contextlib.ExitStack
167
168    def __enter__(self) -> 'TestRunner':
169        self._stack = contextlib.ExitStack()
170        self._stack.enter_context(self.env)
171        self._stack.enter_context(self.last_elapsed)
172        self._stack.enter_context(savetty())
173        return self
174
175    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
176        self._stack.close()
177
178    def test_print_one_line(self, test: str,
179                            test_field_width: int,
180                            starttime: str,
181                            endtime: Optional[str] = None, status: str = '...',
182                            lasttime: Optional[float] = None,
183                            thistime: Optional[float] = None,
184                            description: str = '',
185                            end: str = '\n') -> None:
186        """ Print short test info before/after test run """
187        test = os.path.basename(test)
188
189        if test_field_width is None:
190            test_field_width = 8
191
192        if self.tap:
193            if status == 'pass':
194                print(f'ok {self.env.imgfmt} {test}')
195            elif status == 'fail':
196                print(f'not ok {self.env.imgfmt} {test}')
197            elif status == 'not run':
198                print(f'ok {self.env.imgfmt} {test} # SKIP')
199            return
200
201        if lasttime:
202            lasttime_s = f' (last: {lasttime:.1f}s)'
203        else:
204            lasttime_s = ''
205        if thistime:
206            thistime_s = f'{thistime:.1f}s'
207        else:
208            thistime_s = '...'
209
210        if endtime:
211            endtime = f'[{endtime}]'
212        else:
213            endtime = ''
214
215        if self.color:
216            if status == 'pass':
217                col = '\033[32m'
218            elif status == 'fail':
219                col = '\033[1m\033[31m'
220            elif status == 'not run':
221                col = '\033[33m'
222            else:
223                col = ''
224
225            col_end = '\033[0m'
226        else:
227            col = ''
228            col_end = ''
229
230        print(f'{test:{test_field_width}} {col}{status:10}{col_end} '
231              f'[{starttime}] {endtime:13}{thistime_s:5} {lasttime_s:14} '
232              f'{description}', end=end)
233
234    def find_reference(self, test: str) -> str:
235        if self.env.cachemode == 'none':
236            ref = f'{test}.out.nocache'
237            if os.path.isfile(ref):
238                return ref
239
240        ref = f'{test}.out.{self.env.imgfmt}'
241        if os.path.isfile(ref):
242            return ref
243
244        ref = f'{test}.{self.env.qemu_default_machine}.out'
245        if os.path.isfile(ref):
246            return ref
247
248        return f'{test}.out'
249
250    def do_run_test(self, test: str, mp: bool) -> TestResult:
251        """
252        Run one test
253
254        :param test: test file path
255        :param mp: if true, we are in a multiprocessing environment, use
256                   personal subdirectories for test run
257
258        Note: this method may be called from subprocess, so it does not
259        change ``self`` object in any way!
260        """
261
262        f_test = Path(test)
263        f_reference = Path(self.find_reference(test))
264
265        if not f_test.exists():
266            return TestResult(status='fail',
267                              description=f'No such test file: {f_test}')
268
269        if not os.access(str(f_test), os.X_OK):
270            sys.exit(f'Not executable: {f_test}')
271
272        if not f_reference.exists():
273            return TestResult(status='not run',
274                              description='No qualified output '
275                                          f'(expected {f_reference})')
276
277        args = [str(f_test.resolve())]
278        env = self.env.prepare_subprocess(args)
279        if mp:
280            # Split test directories, so that tests running in parallel don't
281            # break each other.
282            for d in ['TEST_DIR', 'SOCK_DIR']:
283                env[d] = os.path.join(env[d], f_test.name)
284                Path(env[d]).mkdir(parents=True, exist_ok=True)
285
286        test_dir = env['TEST_DIR']
287        f_bad = Path(test_dir, f_test.name + '.out.bad')
288        f_notrun = Path(test_dir, f_test.name + '.notrun')
289        f_casenotrun = Path(test_dir, f_test.name + '.casenotrun')
290
291        for p in (f_notrun, f_casenotrun):
292            silent_unlink(p)
293
294        t0 = time.time()
295        with f_bad.open('w', encoding="utf-8") as f:
296            with subprocess.Popen(args, cwd=str(f_test.parent), env=env,
297                                  stdout=f, stderr=subprocess.STDOUT) as proc:
298                try:
299                    proc.wait()
300                except KeyboardInterrupt:
301                    proc.terminate()
302                    proc.wait()
303                    return TestResult(status='not run',
304                                      description='Interrupted by user',
305                                      interrupted=True)
306                ret = proc.returncode
307
308        elapsed = round(time.time() - t0, 1)
309
310        if ret != 0:
311            return TestResult(status='fail', elapsed=elapsed,
312                              description=f'failed, exit status {ret}',
313                              diff=file_diff(str(f_reference), str(f_bad)))
314
315        if f_notrun.exists():
316            return TestResult(
317                status='not run',
318                description=f_notrun.read_text(encoding='utf-8').strip())
319
320        casenotrun = ''
321        if f_casenotrun.exists():
322            casenotrun = f_casenotrun.read_text(encoding='utf-8')
323
324        diff = file_diff(str(f_reference), str(f_bad))
325        if diff:
326            if os.environ.get("QEMU_IOTESTS_REGEN", None) is not None:
327                shutil.copyfile(str(f_bad), str(f_reference))
328                print("########################################")
329                print("#####    REFERENCE FILE UPDATED    #####")
330                print("########################################")
331            return TestResult(status='fail', elapsed=elapsed,
332                              description=f'output mismatch (see {f_bad})',
333                              diff=diff, casenotrun=casenotrun)
334        else:
335            f_bad.unlink()
336            return TestResult(status='pass', elapsed=elapsed,
337                              casenotrun=casenotrun)
338
339    def run_test(self, test: str,
340                 test_field_width: int,
341                 mp: bool = False) -> TestResult:
342        """
343        Run one test and print short status
344
345        :param test: test file path
346        :param test_field_width: width for first field of status format
347        :param mp: if true, we are in a multiprocessing environment, don't try
348                   to rewrite things in stdout
349
350        Note: this method may be called from subprocess, so it does not
351        change ``self`` object in any way!
352        """
353
354        last_el = self.last_elapsed.get(test)
355        start = datetime.datetime.now().strftime('%H:%M:%S')
356
357        if not self.tap:
358            self.test_print_one_line(test=test,
359                                     test_field_width=test_field_width,
360                                     status = 'started' if mp else '...',
361                                     starttime=start,
362                                     lasttime=last_el,
363                                     end = '\n' if mp else '\r')
364
365        res = self.do_run_test(test, mp)
366
367        end = datetime.datetime.now().strftime('%H:%M:%S')
368        self.test_print_one_line(test=test,
369                                 test_field_width=test_field_width,
370                                 status=res.status,
371                                 starttime=start, endtime=end,
372                                 lasttime=last_el, thistime=res.elapsed,
373                                 description=res.description)
374
375        if res.casenotrun:
376            if self.tap:
377                print('#' + res.casenotrun.replace('\n', '\n#'))
378            else:
379                print(res.casenotrun)
380
381        return res
382
383    def run_tests(self, tests: List[str], jobs: int = 1) -> bool:
384        n_run = 0
385        failed = []
386        notrun = []
387        casenotrun = []
388
389        if self.tap:
390            self.env.print_env('# ')
391        else:
392            self.env.print_env()
393
394        test_field_width = max(len(os.path.basename(t)) for t in tests) + 2
395
396        if jobs > 1:
397            results = self.run_tests_pool(tests, test_field_width, jobs)
398
399        for i, t in enumerate(tests):
400            name = os.path.basename(t)
401
402            if jobs > 1:
403                res = results[i]
404            else:
405                res = self.run_test(t, test_field_width)
406
407            assert res.status in ('pass', 'fail', 'not run')
408
409            if res.casenotrun:
410                casenotrun.append(t)
411
412            if res.status != 'not run':
413                n_run += 1
414
415            if res.status == 'fail':
416                failed.append(name)
417                if res.diff:
418                    if self.tap:
419                        print('\n'.join(res.diff), file=sys.stderr)
420                    else:
421                        print('\n'.join(res.diff))
422            elif res.status == 'not run':
423                notrun.append(name)
424            elif res.status == 'pass':
425                assert res.elapsed is not None
426                self.last_elapsed.update(t, res.elapsed)
427
428            sys.stdout.flush()
429            if res.interrupted:
430                break
431
432        if not self.tap:
433            if notrun:
434                print('Not run:', ' '.join(notrun))
435
436            if casenotrun:
437                print('Some cases not run in:', ' '.join(casenotrun))
438
439            if failed:
440                print('Failures:', ' '.join(failed))
441                print(f'Failed {len(failed)} of {n_run} iotests')
442            else:
443                print(f'Passed all {n_run} iotests')
444        return not failed
445