OLD | NEW |
---|---|
1 # -*- coding: utf-8 -*- | 1 # -*- coding: utf-8 -*- |
2 # Copyright 2014 The Chromium Authors. All rights reserved. | 2 # Copyright 2014 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 """Encapsulates running tests defined in tests.py. | 6 """Encapsulates running tests defined in tests.py. |
7 | 7 |
8 Running this script requires passing --config-path with a path to a config file | 8 Running this script requires passing --config-path with a path to a config file |
9 of the following structure: | 9 of the following structure: |
10 | 10 |
(...skipping 13 matching lines...) Expand all Loading... | |
24 | 24 |
25 The script uses the Python's logging library to report the test results, | 25 The script uses the Python's logging library to report the test results, |
26 as well as debugging information. It emits three levels of logs (in | 26 as well as debugging information. It emits three levels of logs (in |
27 descending order of severity): | 27 descending order of severity): |
28 logging.INFO: Summary of the tests. | 28 logging.INFO: Summary of the tests. |
29 logging.DEBUG: Details about tests failures. | 29 logging.DEBUG: Details about tests failures. |
30 SCRIPT_DEBUG (see below): Debug info of this script. | 30 SCRIPT_DEBUG (see below): Debug info of this script. |
31 You have to set up appropriate logging handlers to have the logs appear. | 31 You have to set up appropriate logging handlers to have the logs appear. |
32 """ | 32 """ |
33 | 33 |
34 import ConfigParser | |
35 import Queue | |
34 import argparse | 36 import argparse |
35 import ConfigParser | |
36 import logging | 37 import logging |
38 import multiprocessing | |
37 import os | 39 import os |
38 import shutil | 40 import shutil |
39 import subprocess | 41 import stopit |
40 import tempfile | 42 import tempfile |
41 import time | 43 import time |
42 | 44 |
45 from threading import Thread | |
46 from collections import defaultdict | |
47 | |
43 import tests | 48 import tests |
44 | 49 |
45 | 50 |
46 # Just below logging.DEBUG, use for this script's debug messages instead | 51 # Just below logging.DEBUG, use for this script's debug messages instead |
47 # of logging.DEBUG, which is already used for detailed test debug messages. | 52 # of logging.DEBUG, which is already used for detailed test debug messages. |
48 SCRIPT_DEBUG = 9 | 53 SCRIPT_DEBUG = 9 |
49 | 54 |
55 class Config: | |
56 test_cases_to_run = tests.TEST_CASES | |
57 save_only_fails = False | |
58 tests_to_run = tests.all_tests.keys() | |
59 max_tests_in_parallel = 1 | |
50 | 60 |
51 class TestRunner(object): | 61 def __init__(self, config_path): |
52 """Runs tests for a single website.""" | 62 config = ConfigParser.ConfigParser() |
63 config.read(config_path) | |
64 if config.has_option("run_options", "tests_in_parallel"): | |
65 self.max_tests_in_parallel = config.getint( | |
66 "run_options", "tests_in_parallel") | |
53 | 67 |
54 def __init__(self, test_cmd, test_name): | 68 self.chrome_path = config.get("binaries", "chrome-path") |
55 """Initialize the TestRunner. | 69 self.chromedriver_path = config.get("binaries", "chromedriver-path") |
70 self.passwords_path = config.get("data_files", "passwords_path") | |
56 | 71 |
57 Args: | 72 if config.has_option("run_options", "tests_to_run"): |
58 test_cmd: List of command line arguments to be supplied to | 73 self.tests_to_run = config.get("run_options", "tests_to_run").split(",") |
59 every test run. | |
60 test_name: Test name (e.g., facebook). | |
61 """ | |
62 self.logger = logging.getLogger("run_tests") | |
63 | 74 |
64 self.profile_path = tempfile.mkdtemp() | 75 if config.has_option("run_options", "test_cases_to_run"): |
65 results = tempfile.NamedTemporaryFile(delete=False) | 76 self.test_cases_to_run = config.get( |
66 self.results_path = results.name | 77 "run_options", "test_cases_to_run").split(",") |
67 results.close() | 78 if (config.has_option("logging", "save-only-fails")): |
68 self.test_cmd = test_cmd + ["--profile-path", self.profile_path, | 79 self.save_only_fails = config.getboolean("logging", "save-only-fails") |
69 "--save-path", self.results_path] | |
70 self.test_name = test_name | |
71 # TODO(vabr): Ideally we would replace timeout with something allowing | |
72 # calling tests directly inside Python, and working on other platforms. | |
73 # | |
74 # The website test runs multiple scenarios, each one has an internal | |
75 # timeout of 200s for waiting (see |remaining_time_to_wait| and | |
76 # Wait() in websitetest.py). Expecting that not every scenario should | |
77 # take 200s, the maximum time allocated for all of them is 300s. | |
78 self.test_cmd = ["timeout", "300"] + self.test_cmd | |
79 | |
80 self.logger.log(SCRIPT_DEBUG, | |
81 "TestRunner set up for test %s, command '%s', " | |
82 "profile path %s, results file %s", | |
83 self.test_name, self.test_cmd, self.profile_path, | |
84 self.results_path) | |
85 | |
86 self.runner_process = None | |
87 # The tests can be flaky. This is why we try to rerun up to 3 times. | |
88 self.max_test_runs_left = 3 | |
89 self.failures = [] | |
90 self._run_test() | |
91 | |
92 def get_test_result(self): | |
93 """Return the test results. | |
94 | |
95 Returns: | |
96 (True, []) if the test passed. | |
97 (False, list_of_failures) if the test failed. | |
98 None if the test is still running. | |
99 """ | |
100 | |
101 test_running = self.runner_process and self.runner_process.poll() is None | |
102 if test_running: | |
103 return None | |
104 # Test is not running, now we have to check if we want to start it again. | |
105 if self._check_if_test_passed(): | |
106 self.logger.log(SCRIPT_DEBUG, "Test %s passed", self.test_name) | |
107 return True, [] | |
108 if self.max_test_runs_left == 0: | |
109 self.logger.log(SCRIPT_DEBUG, "Test %s failed", self.test_name) | |
110 return False, self.failures | |
111 self._run_test() | |
112 return None | |
113 | |
114 def _check_if_test_passed(self): | |
115 """Returns True if and only if the test passed.""" | |
116 | |
117 success = False | |
118 if os.path.isfile(self.results_path): | |
119 with open(self.results_path, "r") as results: | |
120 # TODO(vabr): Parse the results to make sure all scenarios succeeded | |
121 # instead of hard-coding here the number of tests scenarios from | |
122 # test.py:main. | |
123 NUMBER_OF_TEST_SCENARIOS = 3 | |
124 passed_scenarios = 0 | |
125 for line in results: | |
126 self.failures.append(line) | |
127 passed_scenarios += line.count("successful='True'") | |
128 success = passed_scenarios == NUMBER_OF_TEST_SCENARIOS | |
129 if success: | |
130 break | |
131 | |
132 self.logger.log( | |
133 SCRIPT_DEBUG, | |
134 "Test run of {0} has succeeded: {1}".format(self.test_name, success)) | |
135 return success | |
136 | |
137 def _run_test(self): | |
138 """Executes the command to run the test.""" | |
139 with open(self.results_path, "w"): | |
140 pass # Just clear the results file. | |
141 shutil.rmtree(path=self.profile_path, ignore_errors=True) | |
142 self.max_test_runs_left -= 1 | |
143 self.logger.log(SCRIPT_DEBUG, "Run of test %s started", self.test_name) | |
144 self.runner_process = subprocess.Popen(self.test_cmd) | |
145 | 80 |
146 | 81 |
147 def _apply_defaults(config, defaults): | 82 def LogResultsOfTestRun(config, results): |
148 """Adds default values from |defaults| to |config|. | 83 """ Logs |results| of a test run. """ |
84 logger = logging.getLogger("run_tests") | |
85 failed_tests = [] | |
86 failed_tests_num = 0 | |
87 for result in results: | |
88 website, test_case, success, reason = result | |
89 if not (config.save_only_fails and success): | |
90 logger.debug("Test case %s has %s on Website %s", test_case, | |
91 website, {True: "passed", False: "failed"}[success]) | |
92 if not success: | |
93 logger.debug("Reason of failure: %s", reason) | |
149 | 94 |
150 Note: This differs from ConfigParser's mechanism for providing defaults in | 95 if not success: |
151 two aspects: | 96 failed_tests.append("%s.%s" % (website, test_case)) |
152 * The "defaults" here become explicit, and are associated with sections. | 97 failed_tests_num += 1 |
153 * Sections get created for the added defaults where needed, that is, if | |
154 they do not exist before. | |
155 | 98 |
156 Args: | 99 logger.info("%d failed test cases out of %d, failing test cases: %s", |
157 config: A ConfigParser instance to be updated | 100 failed_tests_num, len(results), |
158 defaults: A dictionary mapping (section_string, option_string) pairs | 101 sorted([name for name in failed_tests])) |
vabr (Chromium)
2015/04/16 14:55:43
nit: indentation seems off
melandory
2015/04/17 08:11:30
Done.
| |
159 to string values. For every section/option combination not already | |
160 contained in |config|, the value from |defaults| is stored in |config|. | |
161 """ | |
162 for (section, option) in defaults: | |
163 if not config.has_section(section): | |
164 config.add_section(section) | |
165 if not config.has_option(section, option): | |
166 config.set(section, option, defaults[(section, option)]) | |
167 | 102 |
168 | 103 |
169 def run_tests(config_path): | 104 def RunTestCaseOnWebsite((website, test_case, config)): |
105 """ Runs a |test_case| on a |website|. In case when |test_case| has | |
106 failed it tries to rerun it. If run takes too long, then it is stopped. | |
107 """ | |
108 | |
109 profile_path = tempfile.mkdtemp() | |
110 # The tests can be flaky. This is why we try to rerun up to 3 times. | |
111 attempts = 3 | |
112 result = ("", "", False, "") | |
113 logger = logging.getLogger("run_tests") | |
114 for _ in xrange(attempts): | |
115 shutil.rmtree(path=profile_path, ignore_errors=True) | |
116 logger.log(SCRIPT_DEBUG, "Run of test case %s of website %s started", | |
117 test_case, website) | |
118 try: | |
119 with stopit.ThreadingTimeout(100) as timeout: | |
120 logger.log(SCRIPT_DEBUG, | |
121 "Run test with parameters: %s %s %s %s %s %s", | |
122 config.chrome_path, config.chromedriver_path, | |
123 profile_path, config.passwords_path, | |
124 website, test_case) | |
125 result = tests.RunTest(config.chrome_path, config.chromedriver_path, | |
126 profile_path, config.passwords_path, | |
127 website, test_case)[0] | |
128 if timeout != timeout.EXECUTED: | |
129 result = (website, test_case, False, "Timeout") | |
130 _, _, success, _ = result | |
131 if success: | |
132 return result | |
133 except Exception as e: | |
134 result = (website, test_case, False, e) | |
135 return result | |
136 | |
137 | |
138 def RunTests(config_path): | |
170 """Runs automated tests. | 139 """Runs automated tests. |
171 | 140 |
172 Runs the tests and returns the results through logging: | 141 Runs the tests and returns the results through logging: |
173 On logging.INFO logging level, it returns the summary of how many tests | 142 On logging.INFO logging level, it returns the summary of how many tests |
174 passed and failed. | 143 passed and failed. |
175 On logging.DEBUG logging level, it returns the failure logs, if any. | 144 On logging.DEBUG logging level, it returns the failure logs, if any. |
176 (On SCRIPT_DEBUG it returns diagnostics for this script.) | 145 (On SCRIPT_DEBUG it returns diagnostics for this script.) |
177 | 146 |
178 Args: | 147 Args: |
179 config_path: The path to the config INI file. See the top of the file | 148 config_path: The path to the config INI file. See the top of the file |
180 for format description. | 149 for format description. |
181 """ | 150 """ |
182 def has_test_run_finished(runner, result): | 151 config = Config(config_path) |
183 result = runner.get_test_result() | |
184 if result: # This test run is finished. | |
185 status, log = result | |
186 results.append((runner.test_name, status, log)) | |
187 return True | |
188 else: | |
189 return False | |
190 | |
191 defaults = {("run_options", "tests_in_parallel"): "1"} | |
192 config = ConfigParser.ConfigParser() | |
193 _apply_defaults(config, defaults) | |
194 config.read(config_path) | |
195 max_tests_in_parallel = config.getint("run_options", "tests_in_parallel") | |
196 full_path = os.path.realpath(__file__) | |
197 tests_dir = os.path.dirname(full_path) | |
198 tests_path = os.path.join(tests_dir, "tests.py") | |
199 test_name_idx = 2 # Index of "test_name_placeholder" below. | |
200 general_test_cmd = ["python", tests_path, "test_name_placeholder", | |
201 "--chrome-path", config.get("binaries", "chrome-path"), | |
202 "--chromedriver-path", | |
203 config.get("binaries", "chromedriver-path"), | |
204 "--passwords-path", | |
205 config.get("data_files", "passwords_path")] | |
206 runners = [] | |
207 if config.has_option("run_options", "tests_to_run"): | |
208 tests_to_run = config.get("run_options", "tests_to_run").split(",") | |
209 else: | |
210 tests_to_run = tests.all_tests.keys() | |
211 | |
212 if config.has_option("run_options", "test_cases_to_run"): | |
213 general_test_cmd += ["--test-cases-to-run", | |
214 config.get("run_options", "test_cases_to_run").replace(",", " ")] | |
215 | |
216 if (config.has_option("logging", "save-only-fails") and | |
217 config.getboolean("logging", "save-only-fails")): | |
218 general_test_cmd.append("--save-only-fails") | |
219 | |
220 logger = logging.getLogger("run_tests") | 152 logger = logging.getLogger("run_tests") |
221 logger.log(SCRIPT_DEBUG, "%d tests to run: %s", len(tests_to_run), | 153 logger.log(SCRIPT_DEBUG, "%d tests to run: %s", len(config.tests_to_run), |
222 tests_to_run) | 154 config.tests_to_run) |
223 results = [] # List of (name, bool_passed, failure_log). | 155 data = [(website, test_case, config) |
224 while len(runners) + len(tests_to_run) > 0: | 156 for website in config.tests_to_run |
225 runners = [runner for runner in runners if not has_test_run_finished( | 157 for test_case in config.test_cases_to_run] |
226 runner, results)] | 158 number_of_processes = min([config.max_tests_in_parallel, |
227 while len(runners) < max_tests_in_parallel and len(tests_to_run): | 159 len(config.test_cases_to_run) * |
228 test_name = tests_to_run.pop() | 160 len(config.tests_to_run)]) |
229 specific_test_cmd = list(general_test_cmd) | 161 p = multiprocessing.Pool(number_of_processes) |
230 specific_test_cmd[test_name_idx] = test_name | 162 results = p.map(RunTestCaseOnWebsite, data) |
231 runners.append(TestRunner(specific_test_cmd, test_name)) | 163 p.close() |
232 time.sleep(1) | 164 p.join() |
233 failed_tests = [(name, log) for (name, passed, log) in results if not passed] | 165 LogResultsOfTestRun(config, results) |
234 logger.info("%d failed tests out of %d, failing tests: %s", | |
235 len(failed_tests), len(results), | |
236 [name for (name, _) in failed_tests]) | |
237 logger.debug("Logs of failing tests: %s", failed_tests) | |
238 | 166 |
239 | 167 |
240 def main(): | 168 def main(): |
241 parser = argparse.ArgumentParser() | 169 parser = argparse.ArgumentParser() |
242 parser.add_argument("config_path", metavar="N", | 170 parser.add_argument("config_path", metavar="N", |
243 help="Path to the config.ini file.") | 171 help="Path to the config.ini file.") |
244 args = parser.parse_args() | 172 args = parser.parse_args() |
245 run_tests(args.config_path) | 173 RunTests(args.config_path) |
246 | 174 |
247 | 175 |
248 if __name__ == "__main__": | 176 if __name__ == "__main__": |
249 main() | 177 main() |
OLD | NEW |