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