1# Copyright 2013 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""This script tests the installer with test cases specified in the config file. 6 7For each test case, it checks that the machine states after the execution of 8each command match the expected machine states. For more details, take a look at 9the design documentation at http://goo.gl/Q0rGM6 10""" 11 12import argparse 13import datetime 14import inspect 15import json 16import os 17import subprocess 18import sys 19import time 20import unittest 21import _winreg 22 23from variable_expander import VariableExpander 24import verifier_runner 25 26 27def LogMessage(message): 28 """Logs a message to stderr. 29 30 Args: 31 message: The message string to be logged. 32 """ 33 now = datetime.datetime.now() 34 frameinfo = inspect.getframeinfo(inspect.currentframe().f_back) 35 filename = os.path.basename(frameinfo.filename) 36 line = frameinfo.lineno 37 sys.stderr.write('[%s:%s(%s)] %s\n' % (now.strftime('%m%d/%H%M%S'), 38 filename, line, message)) 39 40 41class Config: 42 """Describes the machine states, actions, and test cases. 43 44 Attributes: 45 states: A dictionary where each key is a state name and the associated value 46 is a property dictionary describing that state. 47 actions: A dictionary where each key is an action name and the associated 48 value is the action's command. 49 tests: An array of test cases. 50 """ 51 def __init__(self): 52 self.states = {} 53 self.actions = {} 54 self.tests = [] 55 56 57class InstallerTest(unittest.TestCase): 58 """Tests a test case in the config file.""" 59 60 def __init__(self, name, test, config, variable_expander, quiet): 61 """Constructor. 62 63 Args: 64 name: The name of this test. 65 test: An array of alternating state names and action names, starting and 66 ending with state names. 67 config: The Config object. 68 variable_expander: A VariableExpander object. 69 """ 70 super(InstallerTest, self).__init__() 71 self._name = name 72 self._test = test 73 self._config = config 74 self._variable_expander = variable_expander 75 self._quiet = quiet 76 self._verifier_runner = verifier_runner.VerifierRunner() 77 self._clean_on_teardown = True 78 79 def __str__(self): 80 """Returns a string representing the test case. 81 82 Returns: 83 A string created by joining state names and action names together with 84 ' -> ', for example, 'Test: clean -> install chrome -> chrome_installed'. 85 """ 86 return '%s: %s\n' % (self._name, ' -> '.join(self._test)) 87 88 def id(self): 89 """Returns the name of the test.""" 90 # Overridden from unittest.TestCase so that id() contains the name of the 91 # test case from the config file in place of the name of this class's test 92 # function. 93 return unittest.TestCase.id(self).replace(self._testMethodName, self._name) 94 95 def runTest(self): 96 """Run the test case.""" 97 # |test| is an array of alternating state names and action names, starting 98 # and ending with state names. Therefore, its length must be odd. 99 self.assertEqual(1, len(self._test) % 2, 100 'The length of test array must be odd') 101 102 state = self._test[0] 103 self._VerifyState(state) 104 105 # Starting at index 1, we loop through pairs of (action, state). 106 for i in range(1, len(self._test), 2): 107 action = self._test[i] 108 if not self._quiet: 109 LogMessage('Beginning action %s' % action) 110 RunCommand(self._config.actions[action], self._variable_expander) 111 if not self._quiet: 112 LogMessage('Finished action %s' % action) 113 114 state = self._test[i + 1] 115 self._VerifyState(state) 116 117 # If the test makes it here, it means it was successful, because RunCommand 118 # and _VerifyState throw an exception on failure. 119 self._clean_on_teardown = False 120 121 def tearDown(self): 122 """Cleans up the machine if the test case fails.""" 123 if self._clean_on_teardown: 124 RunCleanCommand(True, self._variable_expander) 125 126 def shortDescription(self): 127 """Overridden from unittest.TestCase. 128 129 We return None as the short description to suppress its printing. 130 The default implementation of this method returns the docstring of the 131 runTest method, which is not useful since it's the same for every test case. 132 The description from the __str__ method is informative enough. 133 """ 134 return None 135 136 def _VerifyState(self, state): 137 """Verifies that the current machine state matches a given state. 138 139 Args: 140 state: A state name. 141 """ 142 if not self._quiet: 143 LogMessage('Verifying state %s' % state) 144 try: 145 self._verifier_runner.VerifyAll(self._config.states[state], 146 self._variable_expander) 147 except AssertionError as e: 148 # If an AssertionError occurs, we intercept it and add the state name 149 # to the error message so that we know where the test fails. 150 raise AssertionError("In state '%s', %s" % (state, e)) 151 152 153def RunCommand(command, variable_expander): 154 """Runs the given command from the current file's directory. 155 156 This function throws an Exception if the command returns with non-zero exit 157 status. 158 159 Args: 160 command: A command to run. It is expanded using Expand. 161 variable_expander: A VariableExpander object. 162 """ 163 expanded_command = variable_expander.Expand(command) 164 script_dir = os.path.dirname(os.path.abspath(__file__)) 165 exit_status = subprocess.call(expanded_command, shell=True, cwd=script_dir) 166 if exit_status != 0: 167 raise Exception('Command %s returned non-zero exit status %s' % ( 168 expanded_command, exit_status)) 169 170 171def DeleteGoogleUpdateRegistration(system_level, variable_expander): 172 """Deletes Chrome's registration with Google Update. 173 174 Args: 175 system_level: True if system-level Chrome is to be deleted. 176 variable_expander: A VariableExpander object. 177 """ 178 root = (_winreg.HKEY_LOCAL_MACHINE if system_level 179 else _winreg.HKEY_CURRENT_USER) 180 key_name = variable_expander.Expand('$CHROME_UPDATE_REGISTRY_SUBKEY') 181 try: 182 key_handle = _winreg.OpenKey(root, key_name, 0, 183 _winreg.KEY_SET_VALUE | 184 _winreg.KEY_WOW64_32KEY) 185 _winreg.DeleteValue(key_handle, 'pv') 186 except WindowsError: 187 # The key isn't present, so there is no value to delete. 188 pass 189 190 191def RunCleanCommand(force_clean, variable_expander): 192 """Puts the machine in the clean state (i.e. Chrome not installed). 193 194 Args: 195 force_clean: A boolean indicating whether to force cleaning existing 196 installations. 197 variable_expander: A VariableExpander object. 198 """ 199 # TODO(sukolsak): Handle Chrome SxS installs. 200 interactive_option = '--interactive' if not force_clean else '' 201 for system_level in (False, True): 202 level_option = '--system-level' if system_level else '' 203 command = ('python uninstall_chrome.py ' 204 '--chrome-long-name="$CHROME_LONG_NAME" ' 205 '--no-error-if-absent %s %s' % 206 (level_option, interactive_option)) 207 RunCommand(command, variable_expander) 208 if force_clean: 209 DeleteGoogleUpdateRegistration(system_level, variable_expander) 210 211 212def MergePropertyDictionaries(current_property, new_property): 213 """Merges the new property dictionary into the current property dictionary. 214 215 This is different from general dictionary merging in that, in case there are 216 keys with the same name, we merge values together in the first level, and we 217 override earlier values in the second level. For more details, take a look at 218 http://goo.gl/uE0RoR 219 220 Args: 221 current_property: The property dictionary to be modified. 222 new_property: The new property dictionary. 223 """ 224 for key, value in new_property.iteritems(): 225 if key not in current_property: 226 current_property[key] = value 227 else: 228 assert(isinstance(current_property[key], dict) and 229 isinstance(value, dict)) 230 # This merges two dictionaries together. In case there are keys with 231 # the same name, the latter will override the former. 232 current_property[key] = dict( 233 current_property[key].items() + value.items()) 234 235 236def ParsePropertyFiles(directory, filenames): 237 """Parses an array of .prop files. 238 239 Args: 240 property_filenames: An array of Property filenames. 241 directory: The directory where the Config file and all Property files 242 reside in. 243 244 Returns: 245 A property dictionary created by merging all property dictionaries specified 246 in the array. 247 """ 248 current_property = {} 249 for filename in filenames: 250 path = os.path.join(directory, filename) 251 new_property = json.load(open(path)) 252 MergePropertyDictionaries(current_property, new_property) 253 return current_property 254 255 256def ParseConfigFile(filename): 257 """Parses a .config file. 258 259 Args: 260 config_filename: A Config filename. 261 262 Returns: 263 A Config object. 264 """ 265 with open(filename, 'r') as fp: 266 config_data = json.load(fp) 267 directory = os.path.dirname(os.path.abspath(filename)) 268 269 config = Config() 270 config.tests = config_data['tests'] 271 for state_name, state_property_filenames in config_data['states']: 272 config.states[state_name] = ParsePropertyFiles(directory, 273 state_property_filenames) 274 for action_name, action_command in config_data['actions']: 275 config.actions[action_name] = action_command 276 return config 277 278 279def IsComponentBuild(mini_installer_path): 280 """ Invokes the mini_installer asking whether it is a component build. 281 282 Args: 283 mini_installer_path: The path to mini_installer.exe. 284 285 Returns: 286 True if the mini_installer is a component build, False otherwise. 287 """ 288 query_command = [ mini_installer_path, '--query-component-build' ] 289 exit_status = subprocess.call(query_command) 290 return exit_status == 0 291 292 293def main(): 294 parser = argparse.ArgumentParser() 295 parser.add_argument('--build-dir', default='out', 296 help='Path to main build directory (the parent of the ' 297 'Release or Debug directory)') 298 parser.add_argument('--target', default='Release', 299 help='Build target (Release or Debug)') 300 parser.add_argument('--force-clean', action='store_true', default=False, 301 help='Force cleaning existing installations') 302 parser.add_argument('-q', '--quiet', action='store_true', default=False, 303 help='Reduce test runner output') 304 parser.add_argument('--write-full-results-to', metavar='FILENAME', 305 help='Path to write the list of full results to.') 306 parser.add_argument('--config', metavar='FILENAME', 307 help='Path to test configuration file') 308 parser.add_argument('test', nargs='*', 309 help='Name(s) of tests to run.') 310 args = parser.parse_args() 311 if not args.config: 312 parser.error('missing mandatory --config FILENAME argument') 313 314 mini_installer_path = os.path.join(args.build_dir, args.target, 315 'mini_installer.exe') 316 assert os.path.exists(mini_installer_path), ('Could not find file %s' % 317 mini_installer_path) 318 319 suite = unittest.TestSuite() 320 321 # Set the env var used by mini_installer.exe to decide to not show UI. 322 os.environ['MINI_INSTALLER_TEST'] = '1' 323 is_component_build = IsComponentBuild(mini_installer_path) 324 if not is_component_build: 325 config = ParseConfigFile(args.config) 326 327 variable_expander = VariableExpander(mini_installer_path) 328 RunCleanCommand(args.force_clean, variable_expander) 329 for test in config.tests: 330 # If tests were specified via |tests|, their names are formatted like so: 331 test_name = '%s/%s/%s' % (InstallerTest.__module__, 332 InstallerTest.__name__, 333 test['name']) 334 if not args.test or test_name in args.test: 335 suite.addTest(InstallerTest(test['name'], test['traversal'], config, 336 variable_expander, args.quiet)) 337 338 verbosity = 2 if not args.quiet else 1 339 result = unittest.TextTestRunner(verbosity=verbosity).run(suite) 340 if is_component_build: 341 sys.stderr.write('Component build is currently unsupported by the ' 342 'mini_installer: http://crbug.com/377839\n') 343 if args.write_full_results_to: 344 with open(args.write_full_results_to, 'w') as fp: 345 json.dump(_FullResults(suite, result, {}), fp, indent=2) 346 fp.write('\n') 347 return 0 if result.wasSuccessful() else 1 348 349 350# TODO(dpranke): Find a way for this to be shared with the mojo and other tests. 351TEST_SEPARATOR = '.' 352 353 354def _FullResults(suite, result, metadata): 355 """Convert the unittest results to the Chromium JSON test result format. 356 357 This matches run-webkit-tests (the layout tests) and the flakiness dashboard. 358 """ 359 360 full_results = {} 361 full_results['interrupted'] = False 362 full_results['path_delimiter'] = TEST_SEPARATOR 363 full_results['version'] = 3 364 full_results['seconds_since_epoch'] = time.time() 365 for md in metadata: 366 key, val = md.split('=', 1) 367 full_results[key] = val 368 369 all_test_names = _AllTestNames(suite) 370 failed_test_names = _FailedTestNames(result) 371 372 full_results['num_failures_by_type'] = { 373 'FAIL': len(failed_test_names), 374 'PASS': len(all_test_names) - len(failed_test_names), 375 } 376 377 full_results['tests'] = {} 378 379 for test_name in all_test_names: 380 value = {} 381 value['expected'] = 'PASS' 382 if test_name in failed_test_names: 383 value['actual'] = 'FAIL' 384 value['is_unexpected'] = True 385 else: 386 value['actual'] = 'PASS' 387 _AddPathToTrie(full_results['tests'], test_name, value) 388 389 return full_results 390 391 392def _AllTestNames(suite): 393 test_names = [] 394 # _tests is protected pylint: disable=W0212 395 for test in suite._tests: 396 if isinstance(test, unittest.suite.TestSuite): 397 test_names.extend(_AllTestNames(test)) 398 else: 399 test_names.append(test.id()) 400 return test_names 401 402 403def _FailedTestNames(result): 404 return set(test.id() for test, _ in result.failures + result.errors) 405 406 407def _AddPathToTrie(trie, path, value): 408 if TEST_SEPARATOR not in path: 409 trie[path] = value 410 return 411 directory, rest = path.split(TEST_SEPARATOR, 1) 412 if directory not in trie: 413 trie[directory] = {} 414 _AddPathToTrie(trie[directory], rest, value) 415 416 417if __name__ == '__main__': 418 sys.exit(main()) 419