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 |