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 7 For each test case, it checks that the machine states after the execution of 8 each command match the expected machine states. For more details, take a look at 9 the design documentation at http://goo.gl/Q0rGM6 10 """ 11 12 import json 13 import optparse 14 import os 15 import subprocess 16 import sys 17 import unittest 18 19 from variable_expander import VariableExpander 20 import verifier_runner 21 22 23 class Config: 24 """Describes the machine states, actions, and test cases. 25 26 Attributes: 27 states: A dictionary where each key is a state name and the associated value 28 is a property dictionary describing that state. 29 actions: A dictionary where each key is an action name and the associated 30 value is the action's command. 31 tests: An array of test cases. 32 """ 33 def __init__(self): 34 self.states = {} 35 self.actions = {} 36 self.tests = [] 37 38 39 class InstallerTest(unittest.TestCase): 40 """Tests a test case in the config file.""" 41 42 def __init__(self, test, config, variable_expander): 43 """Constructor. 44 45 Args: 46 test: An array of alternating state names and action names, starting and 47 ending with state names. 48 config: The Config object. 49 variable_expander: A VariableExpander object. 50 """ 51 super(InstallerTest, self).__init__() 52 self._test = test 53 self._config = config 54 self._variable_expander = variable_expander 55 self._verifier_runner = verifier_runner.VerifierRunner() 56 self._clean_on_teardown = True 57 58 def __str__(self): 59 """Returns a string representing the test case. 60 61 Returns: 62 A string created by joining state names and action names together with 63 ' -> ', for example, 'Test: clean -> install chrome -> chrome_installed'. 64 """ 65 return 'Test: %s\n' % (' -> '.join(self._test)) 66 67 def runTest(self): 68 """Run the test case.""" 69 # |test| is an array of alternating state names and action names, starting 70 # and ending with state names. Therefore, its length must be odd. 71 self.assertEqual(1, len(self._test) % 2, 72 'The length of test array must be odd') 73 74 state = self._test[0] 75 self._VerifyState(state) 76 77 # Starting at index 1, we loop through pairs of (action, state). 78 for i in range(1, len(self._test), 2): 79 action = self._test[i] 80 RunCommand(self._config.actions[action], self._variable_expander) 81 82 state = self._test[i + 1] 83 self._VerifyState(state) 84 85 # If the test makes it here, it means it was successful, because RunCommand 86 # and _VerifyState throw an exception on failure. 87 self._clean_on_teardown = False 88 89 def tearDown(self): 90 """Cleans up the machine if the test case fails.""" 91 if self._clean_on_teardown: 92 RunCleanCommand(True, self._variable_expander) 93 94 def shortDescription(self): 95 """Overridden from unittest.TestCase. 96 97 We return None as the short description to suppress its printing. 98 The default implementation of this method returns the docstring of the 99 runTest method, which is not useful since it's the same for every test case. 100 The description from the __str__ method is informative enough. 101 """ 102 return None 103 104 def _VerifyState(self, state): 105 """Verifies that the current machine state matches a given state. 106 107 Args: 108 state: A state name. 109 """ 110 try: 111 self._verifier_runner.VerifyAll(self._config.states[state], 112 self._variable_expander) 113 except AssertionError as e: 114 # If an AssertionError occurs, we intercept it and add the state name 115 # to the error message so that we know where the test fails. 116 raise AssertionError("In state '%s', %s" % (state, e)) 117 118 119 def RunCommand(command, variable_expander): 120 """Runs the given command from the current file's directory. 121 122 This function throws an Exception if the command returns with non-zero exit 123 status. 124 125 Args: 126 command: A command to run. It is expanded using Expand. 127 variable_expander: A VariableExpander object. 128 """ 129 expanded_command = variable_expander.Expand(command) 130 script_dir = os.path.dirname(os.path.abspath(__file__)) 131 exit_status = subprocess.call(expanded_command, shell=True, cwd=script_dir) 132 if exit_status != 0: 133 raise Exception('Command %s returned non-zero exit status %s' % ( 134 expanded_command, exit_status)) 135 136 137 def RunCleanCommand(force_clean, variable_expander): 138 """Puts the machine in the clean state (i.e. Chrome not installed). 139 140 Args: 141 force_clean: A boolean indicating whether to force cleaning existing 142 installations. 143 variable_expander: A VariableExpander object. 144 """ 145 # TODO(sukolsak): Read the clean state from the config file and clean 146 # the machine according to it. 147 # TODO(sukolsak): Handle Chrome SxS installs. 148 commands = [] 149 interactive_option = '--interactive' if not force_clean else '' 150 for level_option in ('', '--system-level'): 151 commands.append('python uninstall_chrome.py ' 152 '--chrome-long-name="$CHROME_LONG_NAME" ' 153 '--no-error-if-absent %s %s' % 154 (level_option, interactive_option)) 155 RunCommand(' && '.join(commands), variable_expander) 156 157 158 def MergePropertyDictionaries(current_property, new_property): 159 """Merges the new property dictionary into the current property dictionary. 160 161 This is different from general dictionary merging in that, in case there are 162 keys with the same name, we merge values together in the first level, and we 163 override earlier values in the second level. For more details, take a look at 164 http://goo.gl/uE0RoR 165 166 Args: 167 current_property: The property dictionary to be modified. 168 new_property: The new property dictionary. 169 """ 170 for key, value in new_property.iteritems(): 171 if key not in current_property: 172 current_property[key] = value 173 else: 174 assert(isinstance(current_property[key], dict) and 175 isinstance(value, dict)) 176 # This merges two dictionaries together. In case there are keys with 177 # the same name, the latter will override the former. 178 current_property[key] = dict( 179 current_property[key].items() + value.items()) 180 181 182 def ParsePropertyFiles(directory, filenames): 183 """Parses an array of .prop files. 184 185 Args: 186 property_filenames: An array of Property filenames. 187 directory: The directory where the Config file and all Property files 188 reside in. 189 190 Returns: 191 A property dictionary created by merging all property dictionaries specified 192 in the array. 193 """ 194 current_property = {} 195 for filename in filenames: 196 path = os.path.join(directory, filename) 197 new_property = json.load(open(path)) 198 MergePropertyDictionaries(current_property, new_property) 199 return current_property 200 201 202 def ParseConfigFile(filename): 203 """Parses a .config file. 204 205 Args: 206 config_filename: A Config filename. 207 208 Returns: 209 A Config object. 210 """ 211 config_data = json.load(open(filename, 'r')) 212 directory = os.path.dirname(os.path.abspath(filename)) 213 214 config = Config() 215 config.tests = config_data['tests'] 216 for state_name, state_property_filenames in config_data['states']: 217 config.states[state_name] = ParsePropertyFiles(directory, 218 state_property_filenames) 219 for action_name, action_command in config_data['actions']: 220 config.actions[action_name] = action_command 221 return config 222 223 224 def RunTests(mini_installer_path, config, force_clean): 225 """Tests the installer using the given Config object. 226 227 Args: 228 mini_installer_path: The path to mini_installer.exe. 229 config: A Config object. 230 force_clean: A boolean indicating whether to force cleaning existing 231 installations. 232 233 Returns: 234 True if all the tests passed, or False otherwise. 235 """ 236 suite = unittest.TestSuite() 237 variable_expander = VariableExpander(mini_installer_path) 238 RunCleanCommand(force_clean, variable_expander) 239 for test in config.tests: 240 suite.addTest(InstallerTest(test, config, variable_expander)) 241 result = unittest.TextTestRunner(verbosity=2).run(suite) 242 return result.wasSuccessful() 243 244 245 def IsComponentBuild(mini_installer_path): 246 """ Invokes the mini_installer asking whether it is a component build. 247 248 Args: 249 mini_installer_path: The path to mini_installer.exe. 250 251 Returns: 252 True if the mini_installer is a component build, False otherwise. 253 """ 254 query_command = mini_installer_path + ' --query-component-build' 255 script_dir = os.path.dirname(os.path.abspath(__file__)) 256 exit_status = subprocess.call(query_command, shell=True, cwd=script_dir) 257 return exit_status != 0 258 259 260 def main(): 261 usage = 'usage: %prog [options] config_filename' 262 parser = optparse.OptionParser(usage, description='Test the installer.') 263 parser.add_option('--build-dir', default='out', 264 help='Path to main build directory (the parent of the ' 265 'Release or Debug directory)') 266 parser.add_option('--target', default='Release', 267 help='Build target (Release or Debug)') 268 parser.add_option('--force-clean', action='store_true', dest='force_clean', 269 default=False, help='Force cleaning existing installations') 270 options, args = parser.parse_args() 271 if len(args) != 1: 272 parser.error('Incorrect number of arguments.') 273 config_filename = args[0] 274 275 mini_installer_path = os.path.join(options.build_dir, options.target, 276 'mini_installer.exe') 277 assert os.path.exists(mini_installer_path), ('Could not find file %s' % 278 mini_installer_path) 279 280 # Set the env var used by mini_installer.exe to decide to not show UI. 281 os.environ['MINI_INSTALLER_TEST'] = '1' 282 if IsComponentBuild(mini_installer_path): 283 print ('Component build is currently unsupported by the mini_installer: ' 284 'http://crbug.com/377839') 285 return 0 286 287 config = ParseConfigFile(config_filename) 288 if not RunTests(mini_installer_path, config, options.force_clean): 289 return 1 290 return 0 291 292 293 if __name__ == '__main__': 294 sys.exit(main()) 295