# Copyright 2013 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """This script tests the installer with test cases specified in the config file. For each test case, it checks that the machine states after the execution of each command match the expected machine states. For more details, take a look at the design documentation at http://goo.gl/Q0rGM6 """ import json import optparse import os import subprocess import sys import unittest from variable_expander import VariableExpander import verifier_runner class Config: """Describes the machine states, actions, and test cases. Attributes: states: A dictionary where each key is a state name and the associated value is a property dictionary describing that state. actions: A dictionary where each key is an action name and the associated value is the action's command. tests: An array of test cases. """ def __init__(self): self.states = {} self.actions = {} self.tests = [] class InstallerTest(unittest.TestCase): """Tests a test case in the config file.""" def __init__(self, test, config, variable_expander): """Constructor. Args: test: An array of alternating state names and action names, starting and ending with state names. config: The Config object. variable_expander: A VariableExpander object. """ super(InstallerTest, self).__init__() self._test = test self._config = config self._variable_expander = variable_expander self._verifier_runner = verifier_runner.VerifierRunner() self._clean_on_teardown = True def __str__(self): """Returns a string representing the test case. Returns: A string created by joining state names and action names together with ' -> ', for example, 'Test: clean -> install chrome -> chrome_installed'. """ return 'Test: %s\n' % (' -> '.join(self._test)) def runTest(self): """Run the test case.""" # |test| is an array of alternating state names and action names, starting # and ending with state names. Therefore, its length must be odd. self.assertEqual(1, len(self._test) % 2, 'The length of test array must be odd') state = self._test[0] self._VerifyState(state) # Starting at index 1, we loop through pairs of (action, state). for i in range(1, len(self._test), 2): action = self._test[i] RunCommand(self._config.actions[action], self._variable_expander) state = self._test[i + 1] self._VerifyState(state) # If the test makes it here, it means it was successful, because RunCommand # and _VerifyState throw an exception on failure. self._clean_on_teardown = False def tearDown(self): """Cleans up the machine if the test case fails.""" if self._clean_on_teardown: RunCleanCommand(True, self._variable_expander) def shortDescription(self): """Overridden from unittest.TestCase. We return None as the short description to suppress its printing. The default implementation of this method returns the docstring of the runTest method, which is not useful since it's the same for every test case. The description from the __str__ method is informative enough. """ return None def _VerifyState(self, state): """Verifies that the current machine state matches a given state. Args: state: A state name. """ try: self._verifier_runner.VerifyAll(self._config.states[state], self._variable_expander) except AssertionError as e: # If an AssertionError occurs, we intercept it and add the state name # to the error message so that we know where the test fails. raise AssertionError("In state '%s', %s" % (state, e)) def RunCommand(command, variable_expander): """Runs the given command from the current file's directory. This function throws an Exception if the command returns with non-zero exit status. Args: command: A command to run. It is expanded using Expand. variable_expander: A VariableExpander object. """ expanded_command = variable_expander.Expand(command) script_dir = os.path.dirname(os.path.abspath(__file__)) exit_status = subprocess.call(expanded_command, shell=True, cwd=script_dir) if exit_status != 0: raise Exception('Command %s returned non-zero exit status %s' % ( expanded_command, exit_status)) def RunCleanCommand(force_clean, variable_expander): """Puts the machine in the clean state (i.e. Chrome not installed). Args: force_clean: A boolean indicating whether to force cleaning existing installations. variable_expander: A VariableExpander object. """ # TODO(sukolsak): Read the clean state from the config file and clean # the machine according to it. # TODO(sukolsak): Handle Chrome SxS installs. commands = [] interactive_option = '--interactive' if not force_clean else '' for level_option in ('', '--system-level'): commands.append('python uninstall_chrome.py ' '--chrome-long-name="$CHROME_LONG_NAME" ' '--no-error-if-absent %s %s' % (level_option, interactive_option)) RunCommand(' && '.join(commands), variable_expander) def MergePropertyDictionaries(current_property, new_property): """Merges the new property dictionary into the current property dictionary. This is different from general dictionary merging in that, in case there are keys with the same name, we merge values together in the first level, and we override earlier values in the second level. For more details, take a look at http://goo.gl/uE0RoR Args: current_property: The property dictionary to be modified. new_property: The new property dictionary. """ for key, value in new_property.iteritems(): if key not in current_property: current_property[key] = value else: assert(isinstance(current_property[key], dict) and isinstance(value, dict)) # This merges two dictionaries together. In case there are keys with # the same name, the latter will override the former. current_property[key] = dict( current_property[key].items() + value.items()) def ParsePropertyFiles(directory, filenames): """Parses an array of .prop files. Args: property_filenames: An array of Property filenames. directory: The directory where the Config file and all Property files reside in. Returns: A property dictionary created by merging all property dictionaries specified in the array. """ current_property = {} for filename in filenames: path = os.path.join(directory, filename) new_property = json.load(open(path)) MergePropertyDictionaries(current_property, new_property) return current_property def ParseConfigFile(filename): """Parses a .config file. Args: config_filename: A Config filename. Returns: A Config object. """ config_data = json.load(open(filename, 'r')) directory = os.path.dirname(os.path.abspath(filename)) config = Config() config.tests = config_data['tests'] for state_name, state_property_filenames in config_data['states']: config.states[state_name] = ParsePropertyFiles(directory, state_property_filenames) for action_name, action_command in config_data['actions']: config.actions[action_name] = action_command return config def RunTests(mini_installer_path, config, force_clean): """Tests the installer using the given Config object. Args: mini_installer_path: The path to mini_installer.exe. config: A Config object. force_clean: A boolean indicating whether to force cleaning existing installations. Returns: True if all the tests passed, or False otherwise. """ suite = unittest.TestSuite() variable_expander = VariableExpander(mini_installer_path) RunCleanCommand(force_clean, variable_expander) for test in config.tests: suite.addTest(InstallerTest(test, config, variable_expander)) result = unittest.TextTestRunner(verbosity=2).run(suite) return result.wasSuccessful() def IsComponentBuild(mini_installer_path): """ Invokes the mini_installer asking whether it is a component build. Args: mini_installer_path: The path to mini_installer.exe. Returns: True if the mini_installer is a component build, False otherwise. """ query_command = mini_installer_path + ' --query-component-build' script_dir = os.path.dirname(os.path.abspath(__file__)) exit_status = subprocess.call(query_command, shell=True, cwd=script_dir) return exit_status != 0 def main(): usage = 'usage: %prog [options] config_filename' parser = optparse.OptionParser(usage, description='Test the installer.') parser.add_option('--build-dir', default='out', help='Path to main build directory (the parent of the ' 'Release or Debug directory)') parser.add_option('--target', default='Release', help='Build target (Release or Debug)') parser.add_option('--force-clean', action='store_true', dest='force_clean', default=False, help='Force cleaning existing installations') options, args = parser.parse_args() if len(args) != 1: parser.error('Incorrect number of arguments.') config_filename = args[0] mini_installer_path = os.path.join(options.build_dir, options.target, 'mini_installer.exe') assert os.path.exists(mini_installer_path), ('Could not find file %s' % mini_installer_path) # Set the env var used by mini_installer.exe to decide to not show UI. os.environ['MINI_INSTALLER_TEST'] = '1' if IsComponentBuild(mini_installer_path): print ('Component build is currently unsupported by the mini_installer: ' 'http://crbug.com/377839') return 0 config = ParseConfigFile(config_filename) if not RunTests(mini_installer_path, config, options.force_clean): return 1 return 0 if __name__ == '__main__': sys.exit(main())