| OLD | NEW |
| 1 #!/usr/bin/python | 1 #!/usr/bin/python |
| 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 | 5 |
| 6 """Provides test coverage for individual recipes. | 6 """Provides test coverage for individual recipes. |
| 7 | 7 |
| 8 Recipe tests are located in ../recipes_test/*.py. | 8 Recipe tests are located in ../recipes_test/*.py. |
| 9 | 9 |
| 10 Each py file's splitext'd name is expected to match a recipe in ../recipes/*.py. | 10 Each py file's splitext'd name is expected to match a recipe in ../recipes/*.py. |
| (...skipping 18 matching lines...) Expand all Loading... |
| 29 If those files look right, make sure they get checked in with your changes. | 29 If those files look right, make sure they get checked in with your changes. |
| 30 | 30 |
| 31 When this file runs as a test (i.e. as `recipes_test.py`), it will re-evaluate | 31 When this file runs as a test (i.e. as `recipes_test.py`), it will re-evaluate |
| 32 the recipes using the test function input data and compare the result to the | 32 the recipes using the test function input data and compare the result to the |
| 33 values recorded in the .expected files. | 33 values recorded in the .expected files. |
| 34 | 34 |
| 35 Additionally, this test cannot pass unless every recipe in ../recipes has 100% | 35 Additionally, this test cannot pass unless every recipe in ../recipes has 100% |
| 36 code coverage when executed via the tests in ../recipes_test. | 36 code coverage when executed via the tests in ../recipes_test. |
| 37 """ | 37 """ |
| 38 | 38 |
| 39 import collections | |
| 40 import contextlib | 39 import contextlib |
| 41 import json | 40 import json |
| 42 import os | 41 import os |
| 43 import sys | 42 import sys |
| 44 | 43 |
| 45 from glob import glob | 44 from glob import glob |
| 46 | 45 |
| 47 import test_env # pylint: disable=W0611 | 46 import test_env # pylint: disable=F0401,W0611 |
| 48 | 47 |
| 49 import coverage | 48 import coverage |
| 50 | 49 |
| 51 import common.python26_polyfill # pylint: disable=W0611 | 50 import common.python26_polyfill # pylint: disable=W0611 |
| 52 import unittest | 51 import unittest |
| 53 | 52 |
| 54 from common import annotator | 53 from common import annotator |
| 54 from slave import recipe_util |
| 55 from slave import annotated_run |
| 56 from slave import recipe_loader |
| 55 | 57 |
| 56 SCRIPT_PATH = os.path.abspath(os.path.dirname(__file__)) | 58 SCRIPT_PATH = os.path.abspath(os.path.dirname(__file__)) |
| 57 ROOT_PATH = os.path.abspath(os.path.join(SCRIPT_PATH, os.pardir, os.pardir, | 59 ROOT_PATH = os.path.abspath(os.path.join(SCRIPT_PATH, os.pardir, os.pardir, |
| 58 os.pardir)) | 60 os.pardir)) |
| 59 SLAVE_DIR = os.path.join(ROOT_PATH, 'slave', 'fake_slave', 'build') | 61 SLAVE_DIR = os.path.join(ROOT_PATH, 'slave', 'fake_slave', 'build') |
| 60 INTERNAL_DIR = os.path.join(ROOT_PATH, os.pardir, 'build_internal') | |
| 61 BASE_DIRS = { | |
| 62 'Public': os.path.dirname(SCRIPT_PATH), | |
| 63 'Internal': os.path.join(INTERNAL_DIR, 'scripts', 'slave'), | |
| 64 } | |
| 65 # TODO(iannucci): Check for duplicate recipe names when we have more than one | |
| 66 # base_dir | |
| 67 | 62 |
| 68 COVERAGE = coverage.coverage( | 63 BASE_DIRS = recipe_util.BASE_DIRS |
| 69 include=([os.path.join(x, 'recipes', '*') for x in BASE_DIRS.values()]+ | 64 COVERAGE = None |
| 70 [os.path.join(SCRIPT_PATH, os.pardir, 'recipe_modules', | |
| 71 '*', 'api.py')]) | |
| 72 ) | |
| 73 | 65 |
| 74 | 66 |
| 75 @contextlib.contextmanager | 67 @contextlib.contextmanager |
| 76 def cover(): | 68 def cover(): |
| 77 COVERAGE.start() | 69 COVERAGE.start() |
| 78 try: | 70 try: |
| 79 yield | 71 yield |
| 80 finally: | 72 finally: |
| 81 COVERAGE.stop() | 73 COVERAGE.stop() |
| 82 | 74 |
| 83 with cover(): | |
| 84 from slave import annotated_run | |
| 85 from slave import recipe_api | |
| 86 | |
| 87 class TestAPI(object): | |
| 88 @staticmethod | |
| 89 def properties_generic(**kwargs): | |
| 90 """ | |
| 91 Merge kwargs into a typical buildbot properties blob, and return the blob. | |
| 92 """ | |
| 93 ret = { | |
| 94 'blamelist': 'cool_dev1337@chromium.org,hax@chromium.org', | |
| 95 'blamelist_real': ['cool_dev1337@chromium.org', 'hax@chromium.org'], | |
| 96 'buildername': 'TestBuilder', | |
| 97 'buildnumber': 571, | |
| 98 'mastername': 'chromium.testing.master', | |
| 99 'slavename': 'TestSlavename', | |
| 100 'workdir': '/path/to/workdir/TestSlavename', | |
| 101 } | |
| 102 ret.update(kwargs) | |
| 103 return ret | |
| 104 | |
| 105 @staticmethod | |
| 106 def properties_scheduled(**kwargs): | |
| 107 """ | |
| 108 Merge kwargs into a typical buildbot properties blob for a job fired off | |
| 109 by a chrome/trunk svn scheduler, and return the blob. | |
| 110 """ | |
| 111 ret = TestAPI.properties_generic( | |
| 112 branch='TestBranch', | |
| 113 project='', | |
| 114 repository='svn://svn-mirror.golo.chromium.org/chrome/trunk', | |
| 115 revision='204787', | |
| 116 ) | |
| 117 ret.update(kwargs) | |
| 118 return ret | |
| 119 | |
| 120 @staticmethod | |
| 121 def properties_tryserver(**kwargs): | |
| 122 """ | |
| 123 Merge kwargs into a typical buildbot properties blob for a job fired off | |
| 124 by a rietveld tryjob on the tryserver, and return the blob. | |
| 125 """ | |
| 126 ret = TestAPI.properties_generic( | |
| 127 branch='', | |
| 128 issue=12853011, | |
| 129 patchset=1, | |
| 130 project='chrome', | |
| 131 repository='', | |
| 132 requester='commit-bot@chromium.org', | |
| 133 revision='HEAD', | |
| 134 rietveld='https://codereview.chromium.org', | |
| 135 root='src', | |
| 136 ) | |
| 137 ret.update(kwargs) | |
| 138 return ret | |
| 139 | |
| 140 | |
| 141 def expected_for(recipe_path, test_name): | 75 def expected_for(recipe_path, test_name): |
| 142 root, name = os.path.split(recipe_path) | 76 root, name = os.path.split(recipe_path) |
| 143 name = os.path.splitext(name)[0] | 77 name = os.path.splitext(name)[0] |
| 144 expect_path = os.path.join(root, '%s.expected' % name) | 78 expect_path = os.path.join(root, '%s.expected' % name) |
| 145 if not os.path.isdir(expect_path): | 79 if not os.path.isdir(expect_path): |
| 146 os.makedirs(expect_path) | 80 os.makedirs(expect_path) |
| 147 return os.path.join(expect_path, test_name+'.json') | 81 return os.path.join(expect_path, test_name+'.json') |
| 148 | 82 |
| 149 | 83 |
| 150 def exec_test_file(recipe_path): | 84 def exec_test_file(recipe_path): |
| 151 gvars = {} | 85 gvars = {} |
| 152 with cover(): | 86 with cover(): |
| 153 execfile(recipe_path, gvars) | 87 execfile(recipe_path, gvars) |
| 154 try: | 88 try: |
| 155 gen = gvars['GenTests'](TestAPI()) | 89 test_api = recipe_loader.CreateTestApi(gvars['DEPS']) |
| 90 gen = gvars['GenTests'](test_api) |
| 156 except Exception, e: | 91 except Exception, e: |
| 157 print "Caught exception while processing %s: %s" % (recipe_path, e) | 92 print "Caught exception while processing %s: %s" % (recipe_path, e) |
| 158 raise | 93 raise |
| 159 try: | 94 try: |
| 160 while True: | 95 while True: |
| 161 with cover(): | 96 with cover(): |
| 162 name, test_data = next(gen) | 97 test_data = next(gen) |
| 163 yield name, test_data | 98 yield test_data |
| 164 except StopIteration: | 99 except StopIteration: |
| 165 pass | 100 pass |
| 101 except: |
| 102 print 'Exception while processing "%s"!' % recipe_path |
| 103 raise |
| 166 | 104 |
| 167 | 105 |
| 168 def execute_test_case(test_data, recipe_path, recipe_name): | 106 def execute_test_case(test_data, recipe_path, recipe_name): |
| 169 test_data = test_data.copy() | 107 try: |
| 170 props = test_data.pop('properties', {}).copy() | 108 props = test_data.properties |
| 171 td = test_data.pop('step_mocks', {}).copy() | 109 props['recipe'] = recipe_name |
| 172 props['recipe'] = recipe_name | |
| 173 | 110 |
| 174 mock_data = test_data.pop('mock', {}) | 111 stream = annotator.StructuredAnnotationStream(stream=open(os.devnull, 'w')) |
| 175 mock_data = collections.defaultdict(lambda: collections.defaultdict(dict), | |
| 176 mock_data) | |
| 177 | 112 |
| 178 assert not test_data, 'Got leftover test data: %s' % test_data | 113 def api(*args, **kwargs): |
| 114 return recipe_loader.CreateRecipeApi(test_data=test_data, *args, **kwargs) |
| 179 | 115 |
| 180 stream = annotator.StructuredAnnotationStream(stream=open(os.devnull, 'w')) | 116 with cover(): |
| 181 | |
| 182 def api(*args, **kwargs): | |
| 183 return recipe_api.CreateRecipeApi(mocks=mock_data, *args, **kwargs) | |
| 184 | |
| 185 with cover(): | |
| 186 try: | |
| 187 step_data = annotated_run.run_steps( | 117 step_data = annotated_run.run_steps( |
| 188 stream, props, props, api, td).steps_ran.values() | 118 stream, props, props, api, test_data).steps_ran.values() |
| 189 return [s.step for s in step_data] | 119 return [s.step for s in step_data] |
| 190 except: | 120 except: |
| 191 print 'Exception while processing "%s"!' % recipe_path | 121 print 'Exception while processing "%s"!' % recipe_path |
| 192 raise | 122 raise |
| 193 | 123 |
| 194 | 124 |
| 195 def train_from_tests((recipe_path, recipe_name)): | 125 def train_from_tests((recipe_path, recipe_name)): |
| 196 for path in glob(expected_for(recipe_path, '*')): | 126 for path in glob(expected_for(recipe_path, '*')): |
| 197 os.unlink(path) | 127 os.unlink(path) |
| 198 | 128 |
| 199 for name, test_data in exec_test_file(recipe_path): | 129 for test_data in exec_test_file(recipe_path): |
| 200 steps = execute_test_case(test_data, recipe_path, recipe_name) | 130 steps = execute_test_case(test_data, recipe_path, recipe_name) |
| 201 expected_path = expected_for(recipe_path, name) | 131 expected_path = expected_for(recipe_path, test_data.name) |
| 202 print 'Writing', expected_path | 132 print 'Writing', expected_path |
| 203 with open(expected_path, 'wb') as f: | 133 with open(expected_path, 'wb') as f: |
| 204 json.dump(steps, f, sort_keys=True, indent=2, separators=(',', ': ')) | 134 json.dump(steps, f, sort_keys=True, indent=2, separators=(',', ': ')) |
| 205 | 135 |
| 206 return True | 136 return True |
| 207 | 137 |
| 208 | 138 |
| 209 def load_tests(loader, _standard_tests, _pattern): | 139 def load_tests(loader, _standard_tests, _pattern): |
| 210 """This method is invoked by unittest.main's automatic testloader.""" | 140 """This method is invoked by unittest.main's automatic testloader.""" |
| 211 def create_test_class((recipe_path, recipe_name)): | 141 def create_test_class((recipe_path, recipe_name)): |
| 212 class RecipeTest(unittest.TestCase): | 142 class RecipeTest(unittest.TestCase): |
| 213 @classmethod | 143 @classmethod |
| 214 def add_test_methods(cls): | 144 def add_test_methods(cls): |
| 215 for name, test_data in exec_test_file(recipe_path): | 145 for test_data in exec_test_file(recipe_path): |
| 216 expected_path = expected_for(recipe_path, name) | 146 expected_path = expected_for(recipe_path, test_data.name) |
| 217 def add_test(test_data, expected_path, recipe_name): | 147 def add_test(test_data, expected_path, recipe_name): |
| 218 def test_(self): | 148 def test_(self): |
| 219 steps = execute_test_case(test_data, recipe_path, recipe_name) | 149 steps = execute_test_case(test_data, recipe_path, recipe_name) |
| 220 # Roundtrip json to get same string encoding as load | 150 # Roundtrip json to get same string encoding as load |
| 221 steps = json.loads(json.dumps(steps)) | 151 steps = json.loads(json.dumps(steps)) |
| 222 with open(expected_path, 'rb') as f: | 152 with open(expected_path, 'rb') as f: |
| 223 expected = json.load(f) | 153 expected = json.load(f) |
| 224 self.assertEqual(steps, expected) | 154 self.assertEqual(steps, expected) |
| 225 test_.__name__ += name | 155 test_.__name__ += test_data.name |
| 226 setattr(cls, test_.__name__, test_) | 156 setattr(cls, test_.__name__, test_) |
| 227 add_test(test_data, expected_path, recipe_name) | 157 add_test(test_data, expected_path, recipe_name) |
| 228 | 158 |
| 229 RecipeTest.add_test_methods() | 159 RecipeTest.add_test_methods() |
| 230 | 160 |
| 231 RecipeTest.__name__ += '_for_%s' % ( | 161 RecipeTest.__name__ += '_for_%s' % ( |
| 232 os.path.splitext(os.path.basename(recipe_path))[0]) | 162 os.path.splitext(os.path.basename(recipe_path))[0]) |
| 233 return RecipeTest | 163 return RecipeTest |
| 234 | 164 |
| 235 suite = unittest.TestSuite() | 165 suite = unittest.TestSuite() |
| 236 for test_class in map(create_test_class, loop_over_recipes()): | 166 for test_class in map(create_test_class, recipe_loader.loop_over_recipes()): |
| 237 suite.addTest(loader.loadTestsFromTestCase(test_class)) | 167 suite.addTest(loader.loadTestsFromTestCase(test_class)) |
| 238 return suite | 168 return suite |
| 239 | 169 |
| 240 | 170 |
| 241 def find_recipes(path, predicate): | |
| 242 for root, _dirs, files in os.walk(path): | |
| 243 for recipe in (f for f in files if predicate(f)): | |
| 244 recipe_path = os.path.join(root, recipe) | |
| 245 yield recipe_path | |
| 246 | |
| 247 | |
| 248 def loop_over_recipes(): | |
| 249 for _name, path in BASE_DIRS.iteritems(): | |
| 250 recipe_dir = os.path.join(path, 'recipes') | |
| 251 for recipe in find_recipes( | |
| 252 recipe_dir, lambda f: f.endswith('.py') and f[0] != '_'): | |
| 253 yield recipe, recipe[len(recipe_dir)+1:-len('.py')] | |
| 254 module_dir = os.path.join(path, 'recipe_modules') | |
| 255 for recipe in find_recipes( | |
| 256 module_dir, lambda f: f.endswith('example.py')): | |
| 257 module_name = os.path.dirname(recipe)[len(module_dir)+1:] | |
| 258 yield recipe, '%s:example' % module_name | |
| 259 | |
| 260 | |
| 261 def main(argv): | 171 def main(argv): |
| 262 if not os.path.exists(SLAVE_DIR): | 172 if not os.path.exists(SLAVE_DIR): |
| 263 os.makedirs(SLAVE_DIR) | 173 os.makedirs(SLAVE_DIR) |
| 264 | 174 |
| 265 os.chdir(SLAVE_DIR) | 175 os.chdir(SLAVE_DIR) |
| 266 | 176 |
| 267 training = False | 177 training = False |
| 268 is_help = False | 178 is_help = False |
| 269 if '--help' in argv or '-h' in argv: | 179 if '--help' in argv or '-h' in argv: |
| 270 print 'Pass --train to enter training mode.' | 180 print 'Pass --train to enter training mode.' |
| 271 print | 181 print |
| 272 is_help = True | 182 is_help = True |
| 273 if '--train' in argv: | 183 if '--train' in argv: |
| 274 argv.remove('--train') | 184 argv.remove('--train') |
| 275 training = True | 185 training = True |
| 276 if '--external' in argv: | 186 if '--external' in argv: |
| 277 argv.remove('--external') | 187 argv.remove('--external') |
| 278 del BASE_DIRS['Internal'] | 188 BASE_DIRS[:] = [d for d in BASE_DIRS if 'internal' not in d] |
| 189 global COVERAGE |
| 190 COVERAGE = coverage.coverage( |
| 191 include=( |
| 192 [os.path.join(x, '*') for x in recipe_util.RECIPE_DIRS()] + |
| 193 [os.path.join(x, '*', '*api.py') for x in recipe_util.MODULE_DIRS()] |
| 194 ) |
| 195 ) |
| 279 | 196 |
| 280 had_errors = False | 197 had_errors = False |
| 281 if training and not is_help: | 198 if training and not is_help: |
| 282 for result in map(train_from_tests, loop_over_recipes()): | 199 for result in map(train_from_tests, recipe_loader.loop_over_recipes()): |
| 283 had_errors = had_errors or result | 200 had_errors = had_errors or result |
| 284 if had_errors: | 201 if had_errors: |
| 285 break | 202 break |
| 286 | 203 |
| 287 retcode = 1 if had_errors else 0 | 204 retcode = 1 if had_errors else 0 |
| 288 | 205 |
| 289 if not training: | 206 if not training: |
| 290 try: | 207 try: |
| 291 unittest.main() | 208 unittest.main() |
| 292 except SystemExit as e: | 209 except SystemExit as e: |
| 293 retcode = e.code or retcode | 210 retcode = e.code or retcode |
| 294 | 211 |
| 295 if not is_help: | 212 if not is_help: |
| 296 total_covered = COVERAGE.report() | 213 total_covered = COVERAGE.report() |
| 297 if total_covered != 100.0: | 214 if total_covered != 100.0: |
| 298 print 'FATAL: Recipes are not at 100% coverage.' | 215 print 'FATAL: Recipes are not at 100% coverage.' |
| 299 retcode = retcode or 2 | 216 retcode = retcode or 2 |
| 300 | 217 |
| 301 if training: | 218 if training: |
| 302 test_env.print_coverage_warning() | 219 test_env.print_coverage_warning() |
| 303 | 220 |
| 304 return retcode | 221 return retcode |
| 305 | 222 |
| 306 | 223 |
| 307 if __name__ == '__main__': | 224 if __name__ == '__main__': |
| 308 sys.exit(main(sys.argv)) | 225 sys.exit(main(sys.argv)) |
| OLD | NEW |