| OLD | NEW |
| (Empty) | |
| 1 # Copyright 2017 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. |
| 4 |
| 5 from __future__ import print_function |
| 6 |
| 7 import argparse |
| 8 import cStringIO |
| 9 import contextlib |
| 10 import copy |
| 11 import coverage |
| 12 import datetime |
| 13 import difflib |
| 14 import functools |
| 15 import json |
| 16 import multiprocessing |
| 17 import os |
| 18 import pprint |
| 19 import re |
| 20 import signal |
| 21 import sys |
| 22 import tempfile |
| 23 import traceback |
| 24 |
| 25 from . import checker |
| 26 from . import config_types |
| 27 from . import loader |
| 28 from . import run |
| 29 from . import step_runner |
| 30 from . import stream |
| 31 |
| 32 |
| 33 # These variables must be set in the dynamic scope of the functions in this |
| 34 # file. We do this instead of passing because they're not picklable, and |
| 35 # that's required by multiprocessing. |
| 36 _UNIVERSE_VIEW = None |
| 37 _ENGINE_FLAGS = None |
| 38 |
| 39 |
| 40 # An event to signal exit, for example on Ctrl-C. |
| 41 _KILL_SWITCH = multiprocessing.Event() |
| 42 |
| 43 |
| 44 # This maps from (recipe_name,test_name) -> yielded test_data. It's outside of |
| 45 # run_recipe so that it can persist between RunRecipe calls in the same process. |
| 46 _GEN_TEST_CACHE = {} |
| 47 |
| 48 |
| 49 # Allow regex patterns to be 'deep copied' by using them as-is. |
| 50 copy._deepcopy_dispatch[re._pattern_type] = copy._deepcopy_atomic |
| 51 |
| 52 |
| 53 class PostProcessError(ValueError): |
| 54 """Exception raised when any of the post-process hooks fails.""" |
| 55 pass |
| 56 |
| 57 |
| 58 @contextlib.contextmanager |
| 59 def coverage_context(include=None): |
| 60 """Context manager that records coverage data.""" |
| 61 c = coverage.coverage(config_file=False, include=include) |
| 62 |
| 63 # Sometimes our strict include lists will result in a run |
| 64 # not adding any coverage info. That's okay, avoid output spam. |
| 65 c._warn_no_data = False |
| 66 |
| 67 c.start() |
| 68 try: |
| 69 yield c |
| 70 finally: |
| 71 c.stop() |
| 72 |
| 73 |
| 74 class TestFailure(object): |
| 75 """Generic class for different kinds of test failures.""" |
| 76 |
| 77 def format(self): |
| 78 """Returns a human-readable description of the failure.""" |
| 79 raise NotImplementedError() |
| 80 |
| 81 |
| 82 class DiffFailure(TestFailure): |
| 83 """Failure when simulated recipe commands don't match recorded expectations. |
| 84 """ |
| 85 |
| 86 def __init__(self, diff): |
| 87 self.diff = diff |
| 88 |
| 89 def format(self): |
| 90 return self.diff |
| 91 |
| 92 |
| 93 class CheckFailure(TestFailure): |
| 94 """Failure when any of the post-process checks fails.""" |
| 95 |
| 96 def __init__(self, check): |
| 97 self.check = check |
| 98 |
| 99 def format(self): |
| 100 return self.check.format(indent=4) |
| 101 |
| 102 |
| 103 class TestResult(object): |
| 104 """Result of running a test.""" |
| 105 |
| 106 def __init__(self, test_description, failures, coverage_data): |
| 107 self.test_description = test_description |
| 108 self.failures = failures |
| 109 self.coverage_data = coverage_data |
| 110 |
| 111 |
| 112 class TestDescription(object): |
| 113 """Identifies a specific test. |
| 114 |
| 115 Deliberately small and picklable for use with multiprocessing.""" |
| 116 |
| 117 def __init__(self, recipe_name, test_name, expect_dir, covers): |
| 118 self.recipe_name = recipe_name |
| 119 self.test_name = test_name |
| 120 self.expect_dir = expect_dir |
| 121 self.covers = covers |
| 122 |
| 123 @property |
| 124 def full_name(self): |
| 125 return '%s.%s' % (self.recipe_name, self.test_name) |
| 126 |
| 127 |
| 128 def expectation_path(expect_dir, test_name): |
| 129 """Returns path where serialized expectation data is stored.""" |
| 130 return os.path.join(expect_dir, test_name + '.json') |
| 131 |
| 132 |
| 133 def run_test(test_description): |
| 134 """Runs a test. Returns TestResults object.""" |
| 135 expected = None |
| 136 path = expectation_path( |
| 137 test_description.expect_dir, test_description.test_name) |
| 138 if os.path.exists(path): |
| 139 with open(path) as f: |
| 140 # TODO(phajdan.jr): why do we need to re-encode golden data files? |
| 141 expected = re_encode(json.load(f)) |
| 142 |
| 143 actual, failed_checks, coverage_data = run_recipe( |
| 144 test_description.recipe_name, test_description.test_name, |
| 145 test_description.covers) |
| 146 actual = re_encode(actual) |
| 147 |
| 148 failures = [] |
| 149 |
| 150 # TODO(phajdan.jr): handle exception (errors) in the recipe execution. |
| 151 if failed_checks: |
| 152 sys.stdout.write('C') |
| 153 failures.extend([CheckFailure(c) for c in failed_checks]) |
| 154 elif actual != expected: |
| 155 diff = '\n'.join(list(difflib.unified_diff( |
| 156 pprint.pformat(expected).splitlines(), |
| 157 pprint.pformat(actual).splitlines(), |
| 158 fromfile='expected', tofile='actual', |
| 159 n=4, lineterm=''))) |
| 160 |
| 161 failures.append(DiffFailure(diff)) |
| 162 sys.stdout.write('F') |
| 163 else: |
| 164 sys.stdout.write('.') |
| 165 sys.stdout.flush() |
| 166 |
| 167 return TestResult(test_description, failures, coverage_data) |
| 168 |
| 169 |
| 170 def run_recipe(recipe_name, test_name, covers): |
| 171 """Runs the recipe under test in simulation mode. |
| 172 |
| 173 Returns a tuple: |
| 174 - expectation data |
| 175 - failed post-process checks (if any) |
| 176 - coverage data |
| 177 """ |
| 178 config_types.ResetTostringFns() |
| 179 |
| 180 # Reload test data in the worker to avoid pickling complex data structures. |
| 181 cache_key = (recipe_name, test_name) |
| 182 if cache_key not in _GEN_TEST_CACHE: |
| 183 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name) |
| 184 test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE_VIEW) |
| 185 for test_data in recipe_script.gen_tests(test_api): |
| 186 _GEN_TEST_CACHE[(recipe_name, test_data.name)] = copy.deepcopy(test_data) |
| 187 test_data = _GEN_TEST_CACHE[cache_key] |
| 188 |
| 189 annotator = SimulationAnnotatorStreamEngine() |
| 190 with stream.StreamEngineInvariants.wrap(annotator) as stream_engine: |
| 191 runner = step_runner.SimulationStepRunner( |
| 192 stream_engine, test_data, annotator) |
| 193 |
| 194 props = test_data.properties.copy() |
| 195 props['recipe'] = recipe_name |
| 196 engine = run.RecipeEngine( |
| 197 runner, props, _UNIVERSE_VIEW, engine_flags=_ENGINE_FLAGS) |
| 198 with coverage_context(include=covers) as cov: |
| 199 # Run recipe loading under coverage context. This ensures we collect |
| 200 # coverage of all definitions and globals. |
| 201 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name, engine=engine) |
| 202 |
| 203 api = loader.create_recipe_api( |
| 204 _UNIVERSE_VIEW.universe.package_deps.root_package, |
| 205 recipe_script.LOADED_DEPS, |
| 206 recipe_script.path, engine, test_data) |
| 207 result = engine.run(recipe_script, api, test_data.properties) |
| 208 coverage_data = cov.get_data() |
| 209 |
| 210 raw_expectations = runner.steps_ran.copy() |
| 211 # Don't include tracebacks in expectations because they are too sensitive |
| 212 # to change. |
| 213 result.result.pop('traceback', None) |
| 214 raw_expectations[result.result['name']] = result.result |
| 215 |
| 216 failed_checks = [] |
| 217 |
| 218 for hook, args, kwargs, filename, lineno in test_data.post_process_hooks: |
| 219 input_odict = copy.deepcopy(raw_expectations) |
| 220 # We ignore the input_odict so that it never gets printed in full. |
| 221 # Usually the check invocation itself will index the input_odict or |
| 222 # will use it only for a key membership comparison, which provides |
| 223 # enough debugging context. |
| 224 checker_obj = checker.Checker( |
| 225 filename, lineno, hook, args, kwargs, input_odict) |
| 226 |
| 227 with coverage_context(include=covers) as cov: |
| 228 # Run the hook itself under coverage. There may be custom post-process |
| 229 # functions in recipe test code. |
| 230 rslt = hook(checker_obj, input_odict, *args, **kwargs) |
| 231 coverage_data.update(cov.get_data()) |
| 232 |
| 233 failed_checks += checker_obj.failed_checks |
| 234 if rslt is not None: |
| 235 msg = checker.VerifySubset(rslt, raw_expectations) |
| 236 if msg: |
| 237 raise PostProcessError('post_process: steps'+msg) |
| 238 # restore 'name' |
| 239 for k, v in rslt.iteritems(): |
| 240 if 'name' not in v: |
| 241 v['name'] = k |
| 242 raw_expectations = rslt |
| 243 |
| 244 # empty means drop expectation |
| 245 result_data = raw_expectations.values() if raw_expectations else None |
| 246 return (result_data, failed_checks, coverage_data) |
| 247 |
| 248 |
| 249 def get_tests(): |
| 250 """Returns a list of tests for current recipe package.""" |
| 251 tests = [] |
| 252 coverage_data = coverage.CoverageData() |
| 253 |
| 254 all_modules = set(_UNIVERSE_VIEW.loop_over_recipe_modules()) |
| 255 covered_modules = set() |
| 256 |
| 257 base_covers = [] |
| 258 |
| 259 # Make sure disabling strict coverage also disables our additional check |
| 260 # for module coverage. Note that coverage will still raise an error if |
| 261 # the module is executed by any of the tests, but having less than 100% |
| 262 # coverage. |
| 263 for module in all_modules: |
| 264 # Run module loading under coverage context. This ensures we collect |
| 265 # coverage of all definitions and globals. |
| 266 coverage_include = os.path.join(_UNIVERSE_VIEW.module_dir, '*', '*.py') |
| 267 with coverage_context(include=coverage_include) as cov: |
| 268 mod = _UNIVERSE_VIEW.load_recipe_module(module) |
| 269 coverage_data.update(cov.get_data()) |
| 270 |
| 271 # Recipe modules can only be covered by tests inside the same module. |
| 272 # To make transition possible for existing code (which will require |
| 273 # writing tests), a temporary escape hatch is added. |
| 274 # TODO(phajdan.jr): remove DISABLE_STRICT_COVERAGE (crbug/693058). |
| 275 if mod.DISABLE_STRICT_COVERAGE: |
| 276 covered_modules.add(module) |
| 277 base_covers.append(os.path.join( |
| 278 _UNIVERSE_VIEW.module_dir, module, '*.py')) |
| 279 |
| 280 for recipe_path, recipe_name in _UNIVERSE_VIEW.loop_over_recipes(): |
| 281 try: |
| 282 covers = [recipe_path] + base_covers |
| 283 |
| 284 # Example/test recipes in a module always cover that module. |
| 285 if ':' in recipe_name: |
| 286 module, _ = recipe_name.split(':', 1) |
| 287 covered_modules.add(module) |
| 288 covers.append(os.path.join(_UNIVERSE_VIEW.module_dir, module, '*.py')) |
| 289 |
| 290 with coverage_context(include=covers) as cov: |
| 291 # Run recipe loading under coverage context. This ensures we collect |
| 292 # coverage of all definitions and globals. |
| 293 recipe = _UNIVERSE_VIEW.load_recipe(recipe_name) |
| 294 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE_VIEW) |
| 295 |
| 296 root, name = os.path.split(recipe_path) |
| 297 name = os.path.splitext(name)[0] |
| 298 # TODO(phajdan.jr): move expectation tree outside of the recipe tree. |
| 299 expect_dir = os.path.join(root, '%s.expected' % name) |
| 300 |
| 301 # Immediately convert to list to force running the generator under |
| 302 # coverage context. Otherwise coverage would only report executing |
| 303 # the function definition, not GenTests body. |
| 304 recipe_tests = list(recipe.gen_tests(test_api)) |
| 305 coverage_data.update(cov.get_data()) |
| 306 |
| 307 for test_data in recipe_tests: |
| 308 tests.append(TestDescription( |
| 309 recipe_name, test_data.name, expect_dir, covers)) |
| 310 except: |
| 311 info = sys.exc_info() |
| 312 new_exec = Exception('While generating results for %r: %s: %s' % ( |
| 313 recipe_name, info[0].__name__, str(info[1]))) |
| 314 raise new_exec.__class__, new_exec, info[2] |
| 315 |
| 316 uncovered_modules = all_modules.difference(covered_modules) |
| 317 return (tests, coverage_data, uncovered_modules) |
| 318 |
| 319 |
| 320 def run_list(): |
| 321 """Implementation of the 'list' command.""" |
| 322 tests, _coverage_data, _uncovered_modules = get_tests() |
| 323 print('\n'.join(sorted(t.full_name for t in tests))) |
| 324 return 0 |
| 325 |
| 326 |
| 327 def cover_omit(): |
| 328 """Returns list of patterns to omit from coverage analysis.""" |
| 329 omit = [ ] |
| 330 |
| 331 mod_dir_base = _UNIVERSE_VIEW.module_dir |
| 332 if os.path.isdir(mod_dir_base): |
| 333 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*')) |
| 334 |
| 335 # Exclude recipe engine files from simulation test coverage. Simulation tests |
| 336 # should cover "user space" recipe code (recipes and modules), not the engine. |
| 337 # The engine is covered by unit tests, not simulation tests. |
| 338 omit.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '*')) |
| 339 |
| 340 return omit |
| 341 |
| 342 |
| 343 def report_coverage_version(): |
| 344 """Prints info about coverage module (for debugging).""" |
| 345 print('Using coverage %s from %r' % (coverage.__version__, coverage.__file__)) |
| 346 |
| 347 |
| 348 def worker(f): |
| 349 """Wrapper for a multiprocessing worker function. |
| 350 |
| 351 This addresses known issues with multiprocessing workers: |
| 352 |
| 353 - they can hang on uncaught exceptions |
| 354 - we need explicit kill switch to clearly terminate parent""" |
| 355 @functools.wraps(f) |
| 356 def wrapper(*args, **kwargs): |
| 357 try: |
| 358 if _KILL_SWITCH.is_set(): |
| 359 return (False, 'kill switch') |
| 360 return (True, f(*args, **kwargs)) |
| 361 except Exception: |
| 362 return (False, traceback.format_exc()) |
| 363 return wrapper |
| 364 |
| 365 |
| 366 @worker |
| 367 def run_worker(test): |
| 368 """Worker for 'run' command (note decorator above).""" |
| 369 return run_test(test) |
| 370 |
| 371 |
| 372 def run_run(jobs): |
| 373 """Implementation of the 'run' command.""" |
| 374 start_time = datetime.datetime.now() |
| 375 |
| 376 report_coverage_version() |
| 377 |
| 378 tests, coverage_data, uncovered_modules = get_tests() |
| 379 if uncovered_modules: |
| 380 raise Exception('The following modules lack test coverage: %s' % ( |
| 381 ','.join(sorted(uncovered_modules)))) |
| 382 |
| 383 with kill_switch(): |
| 384 pool = multiprocessing.Pool(jobs) |
| 385 results = pool.map(run_worker, tests) |
| 386 |
| 387 print() |
| 388 |
| 389 rc = 0 |
| 390 for success, details in results: |
| 391 if success: |
| 392 assert isinstance(details, TestResult) |
| 393 if details.failures: |
| 394 rc = 1 |
| 395 print('%s failed:' % details.test_description.full_name) |
| 396 for failure in details.failures: |
| 397 print(failure.format()) |
| 398 coverage_data.update(details.coverage_data) |
| 399 else: |
| 400 rc = 1 |
| 401 print('Internal failure:') |
| 402 print(details) |
| 403 |
| 404 try: |
| 405 # TODO(phajdan.jr): Add API to coverage to load data from memory. |
| 406 with tempfile.NamedTemporaryFile(delete=False) as coverage_file: |
| 407 coverage_data.write_file(coverage_file.name) |
| 408 |
| 409 cov = coverage.coverage( |
| 410 data_file=coverage_file.name, config_file=False, omit=cover_omit()) |
| 411 cov.load() |
| 412 outf = cStringIO.StringIO() |
| 413 percentage = cov.report(file=outf, show_missing=True, skip_covered=True) |
| 414 if int(percentage) != 100: |
| 415 rc = 1 |
| 416 print(outf.getvalue()) |
| 417 print('FATAL: Insufficient coverage (%.f%%)' % int(percentage)) |
| 418 finally: |
| 419 os.unlink(coverage_file.name) |
| 420 |
| 421 finish_time = datetime.datetime.now() |
| 422 print('-' * 70) |
| 423 print('Ran %d tests in %0.3fs' % ( |
| 424 len(tests), (finish_time - start_time).total_seconds())) |
| 425 print() |
| 426 print('OK' if rc == 0 else 'FAILED') |
| 427 |
| 428 return rc |
| 429 |
| 430 |
| 431 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): |
| 432 """Stream engine which just records generated commands.""" |
| 433 |
| 434 def __init__(self): |
| 435 self._step_buffer_map = {} |
| 436 super(SimulationAnnotatorStreamEngine, self).__init__( |
| 437 self.step_buffer(None)) |
| 438 |
| 439 def step_buffer(self, step_name): |
| 440 return self._step_buffer_map.setdefault(step_name, cStringIO.StringIO()) |
| 441 |
| 442 def new_step_stream(self, step_config): |
| 443 return self._create_step_stream(step_config, |
| 444 self.step_buffer(step_config.name)) |
| 445 |
| 446 |
| 447 def handle_killswitch(*_): |
| 448 """Function invoked by ctrl-c. Signals worker processes to exit.""" |
| 449 _KILL_SWITCH.set() |
| 450 |
| 451 # Reset the signal to DFL so that double ctrl-C kills us for sure. |
| 452 signal.signal(signal.SIGINT, signal.SIG_DFL) |
| 453 signal.signal(signal.SIGTERM, signal.SIG_DFL) |
| 454 |
| 455 |
| 456 @contextlib.contextmanager |
| 457 def kill_switch(): |
| 458 """Context manager to handle ctrl-c properly with multiprocessing.""" |
| 459 orig_sigint = signal.signal(signal.SIGINT, handle_killswitch) |
| 460 try: |
| 461 orig_sigterm = signal.signal(signal.SIGTERM, handle_killswitch) |
| 462 try: |
| 463 yield |
| 464 finally: |
| 465 signal.signal(signal.SIGTERM, orig_sigterm) |
| 466 finally: |
| 467 signal.signal(signal.SIGINT, orig_sigint) |
| 468 |
| 469 if _KILL_SWITCH.is_set(): |
| 470 sys.exit(1) |
| 471 |
| 472 |
| 473 def re_encode(obj): |
| 474 """Ensure consistent encoding for common python data structures.""" |
| 475 if isinstance(obj, dict): |
| 476 return {re_encode(k): re_encode(v) for k, v in obj.iteritems()} |
| 477 elif isinstance(obj, list): |
| 478 return [re_encode(i) for i in obj] |
| 479 elif isinstance(obj, (unicode, str)): |
| 480 if isinstance(obj, str): |
| 481 obj = obj.decode('utf-8', 'replace') |
| 482 return obj.encode('utf-8', 'replace') |
| 483 else: |
| 484 return obj |
| 485 |
| 486 |
| 487 def parse_args(args): |
| 488 """Returns parsed command line arguments.""" |
| 489 parser = argparse.ArgumentParser() |
| 490 |
| 491 subp = parser.add_subparsers() |
| 492 |
| 493 list_p = subp.add_parser('list', description='Print all test names') |
| 494 list_p.set_defaults(func=lambda opts: run_list()) |
| 495 |
| 496 # TODO(phajdan.jr): support running a subset of tests. |
| 497 run_p = subp.add_parser('run', description='Run the tests') |
| 498 run_p.set_defaults(func=lambda opts: run_run(opts.jobs)) |
| 499 run_p.add_argument( |
| 500 '--jobs', metavar='N', type=int, |
| 501 default=multiprocessing.cpu_count(), |
| 502 help='run N jobs in parallel (default %(default)s)') |
| 503 |
| 504 return parser.parse_args(args) |
| 505 |
| 506 |
| 507 def main(universe_view, raw_args, engine_flags): |
| 508 """Runs simulation tests on a given repo of recipes. |
| 509 |
| 510 Args: |
| 511 universe_view: an UniverseView object to operate on |
| 512 raw_args: command line arguments to simulation_test_ng |
| 513 engine_flags: recipe engine command-line flags |
| 514 Returns: |
| 515 Exit code |
| 516 """ |
| 517 global _UNIVERSE_VIEW |
| 518 _UNIVERSE_VIEW = universe_view |
| 519 global _ENGINE_FLAGS |
| 520 _ENGINE_FLAGS = engine_flags |
| 521 |
| 522 args = parse_args(raw_args) |
| 523 return args.func(args) |
| OLD | NEW |