Adding parser to report desktopui/gtest individual test status.

Added a new method to server_job to run gtests.

Added two new classes to server_job_utils to run and parse gtests.

Added three new suites to hold individual gtest suites that were in one browertest suite.

http://pauldean.kir/afe/#tab_id=view_job&object_id=259
http://pauldean.kir/afe/#tab_id=view_job&object_id=260
http://pauldean.kir/afe/#tab_id=view_job&object_id=261

BUG=None.
TEST=Lots of runs on local and lab machines.

Change-Id: I6c02c40d7889ade5342ccdd99da7f5d15a9f50d8

Fixes from code review.

Added gtest_runner.py to hold runner/parser for gtests.

Added host_attributes.get_attributes() method.

Updated documentation.

BUG=None.
TEST=Runs on lab machines.

Change-Id: Id591da132037bbc277c4dcb2353cbc2a61103444

Code review updates.

Removing server_job.run_gtest_tests() and making gtest_runner available in the environment of the control files.

Adding test_item.run_tests() to make it easer to execute tests from different classes.

Adding actions to test tuple to support reboot before/after running tests.

Removing uneeded methods on host_attributes class.

Reworking documentation in site_host_attributes to fix pylint errors.

Changing control files from job.run_gtest_tests() to gtest_runner.run()

Change-Id: I9f36f1176b24226ee83826cf84e5125c8af87b76

BUG=None.
TEST=Lots of runs on local and lab machines.

Change-Id: If8696119bcd391de1adc88cc95d55fdb74cf2129
Reviewed-on: http://gerrit.chromium.org/gerrit/343
Reviewed-by: Eric Li <[email protected]>
Tested-by: Eric Li <[email protected]>
diff --git a/server/gtest_runner.py b/server/gtest_runner.py
new file mode 100644
index 0000000..5efc0fb
--- /dev/null
+++ b/server/gtest_runner.py
@@ -0,0 +1,424 @@
+# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Utility classes used to run and parse a gest suite in autotest.
+
+gtest_runner: runs a gtest suite parsing individual tests.
+
+gtest_parser: understands the output of a gtest run.
+"""
+
+
+import logging, os, re
+from autotest_lib.server import autotest, hosts, host_attributes
+from autotest_lib.server import server_job_utils
+
+
+class gtest_runner(object):
+    """Run a gtest test suite and evaluate the individual tests."""
+
+    def __init__(self):
+        """Creates an instance of gtest_runner to run tests on a remote host."""
+        self._results_dir = ''
+        self._gtest = None
+        self._host = None
+
+    def run(self, gtest_entry, machine, work_dir='.'):
+        """Run the gtest suite on a remote host, then parse the results.
+
+        Like machine_worker, gtest_runner honors include/exclude attributes on
+        the test item and will only run the test if the supplied host meets the
+        test requirements.
+
+        Note: This method takes a test and a machine as arguments, not a list
+        of tests and a list of machines like the parallel and distribute
+        methods do.
+
+        Args:
+            gtest_entry: Test tuple from control file.  See documentation in
+                server_job_utils.test_item class.
+            machine: Name (IP) if remote host to run tests on.
+            work_dir: Local directory to run tests in.
+
+        """
+        self._gtest = server_job_utils.test_item(*gtest_entry)
+        self._host = hosts.create_host(machine)
+        self._results_dir = work_dir
+
+        client_autotest = autotest.Autotest(self._host)
+        client_attributes = host_attributes.host_attributes(machine)
+        attribute_set = set(client_attributes.get_attributes())
+
+        if self._gtest.validate(attribute_set):
+            logging.info('%s %s Running %s', self._host,
+                         [a for a in attribute_set], self._gtest)
+            self._gtest.run_test(client_autotest, self._results_dir)
+            self.parse()
+        else:
+            self.record_failed_test(self._gtest.test_name,
+                                    'No machines found for: ' + self._gtest)
+
+    def parse(self):
+        """Parse the gtest output recording individual test results.
+
+        Uses gtest_parser to pull the test results out of the gtest log file.
+        Then creates entries  in status.log file for each test.
+        """
+        parser = gtest_parser()
+
+        # Find gtest log files from the autotest client run.
+        log_path = os.path.join(self._results_dir, self._gtest.test_name,
+                                'debug', self._gtest.test_name + '.DEBUG')
+
+        # Read the log file line-by-line, passing each line into the parser.
+        with open(log_path, 'r') as log_file:
+            for log_line in log_file:
+                parser.ProcessLogLine(log_line)
+
+        logging.info('gtest_runner found %d tests.', parser.TotalTests())
+
+        # Record each failed test.
+        for failed in parser.FailedTests():
+            fail_description = parser.FailureDescription(failed)
+            self.record_failed_test(failed, fail_description[1].strip(),
+                                    ''.join(fail_description[1:]))
+
+        # Finally record each successful test.
+        for passed in parser.PassedTests():
+            self.record_passed_test(passed)
+
+    def record_failed_test(self, failed_test, message, error_lines=None):
+        """Insert a failure record into status.log for this test.
+
+        Args:
+           failed_test: Name of test that failed.
+           message: Reason test failed, will be put in status.log file.
+           error_lines: Additional failure info, will be put in ERROR log.
+        """
+        self._host.record('START', None, failed_test)
+        self._host.record('INFO', None, 'FAILED: ' + failed_test)
+        self._host.record('END FAIL', None, failed_test, message)
+
+        # If we have additional information on the failure, create an error log
+        # file for this test in the location a normal autotest would have left
+        # it so the frontend knows where to find it.
+        if error_lines is not None:
+            fail_log_dir = os.path.join(self._results_dir, failed_test, 'debug')
+            fail_log_path = os.path.join(fail_log_dir, failed_test + '.ERROR')
+
+            if not os.path.exists(fail_log_dir):
+                try:
+                    os.makedirs(fail_log_dir)
+                except OSError:
+                    logging.exception('Failed to created log directory: %s',
+                                      fail_log_dir)
+                    return
+            try:
+                with open(fail_log_path, 'w') as fail_log:
+                    fail_log.write(error_lines)
+            except IOError:
+                logging.exception('Failed to open log file: %s', fail_log_path)
+
+    def record_passed_test(self, passed_test):
+        """Insert a failure record into status.log for this test.
+
+        Args:
+            passed_test: Name of test that passed.
+        """
+        self._host.record('START', None, passed_test)
+        self._host.record('INFO', None, 'PASSED: ' + passed_test)
+        self._host.record('END GOOD', None, passed_test)
+
+
+class gtest_parser(object):
+    """This class knows how to understand GTest test output.
+
+    The code was borrowed with minor changes from chrome utility gtest_command.
+        http://src.chromium.org/viewvc/chrome/trunk/tools/build/scripts/master/
+        log_parser/gtest_command.py?view=markup
+    """
+
+    def __init__(self):
+        # State tracking for log parsing
+        self._current_test = ''
+        self._failure_description = []
+        self._current_suppression_hash = ''
+        self._current_suppression = []
+
+        # Line number currently being processed.
+        self._line_number = 0
+
+        # List of parsing errors, as human-readable strings.
+        self.internal_error_lines = []
+
+        # Tests are stored here as 'test.name': (status, [description]).
+        # The status should be one of ('started', 'OK', 'failed', 'timeout').
+        # The description is a list of lines detailing the test's error, as
+        # reported in the log.
+        self._test_status = {}
+
+        # Suppressions are stored here as 'hash': [suppression].
+        self._suppressions = {}
+
+        # This may be either text or a number. It will be used in the phrase
+        # '%s disabled' or '%s flaky' on the waterfall display.
+        self.disabled_tests = 0
+        self.flaky_tests = 0
+
+        # Regular expressions for parsing GTest logs. Test names look like
+        #   SomeTestCase.SomeTest
+        #   SomeName/SomeTestCase.SomeTest/1
+        # This regexp also matches SomeName.SomeTest/1, which should be
+        # harmless.
+        test_name_regexp = r'((\w+/)?\w+\.\w+(\.\w+)?(/\d+)?)'
+        self._test_start = re.compile('\[\s+RUN\s+\] ' + test_name_regexp)
+        self._test_ok = re.compile('\[\s+OK\s+\] ' + test_name_regexp)
+        self._test_fail = re.compile('\[\s+FAILED\s+\] ' + test_name_regexp)
+        self._test_timeout = re.compile(
+            'Test timeout \([0-9]+ ms\) exceeded for ' + test_name_regexp)
+        self._disabled = re.compile('  YOU HAVE (\d+) DISABLED TEST')
+        self._flaky = re.compile('  YOU HAVE (\d+) FLAKY TEST')
+
+        self._suppression_start = re.compile(
+            'Suppression \(error hash=#([0-9A-F]+)#\):')
+        self._suppression_end = re.compile('^}\s*$')
+
+        self._master_name_re = re.compile('\[Running for master: "([^"]*)"')
+        self.master_name = ''
+
+    def _TestsByStatus(self, status, include_fails, include_flaky):
+        """Returns list of tests with the given status.
+
+        Args:
+            status: test results status to search for.
+            include_fails: If False, tests containing 'FAILS_' anywhere in
+                their names will be excluded from the list.
+            include_flaky: If False, tests containing 'FLAKY_' anywhere in
+                their names will be excluded from the list.
+        Returns:
+            List of tests with the status.
+        """
+        test_list = [x[0] for x in self._test_status.items()
+                     if self._StatusOfTest(x[0]) == status]
+
+        if not include_fails:
+            test_list = [x for x in test_list if x.find('FAILS_') == -1]
+        if not include_flaky:
+            test_list = [x for x in test_list if x.find('FLAKY_') == -1]
+
+        return test_list
+
+    def _StatusOfTest(self, test):
+        """Returns the status code for the given test, or 'not known'."""
+        test_status = self._test_status.get(test, ('not known', []))
+        return test_status[0]
+
+    def _RecordError(self, line, reason):
+        """Record a log line that produced a parsing error.
+
+        Args:
+            line: text of the line at which the error occurred.
+            reason: a string describing the error.
+        """
+        self.internal_error_lines.append("%s: %s [%s]" % (self._line_number,
+                                                          line.strip(),
+                                                          reason))
+
+    def TotalTests(self):
+        """Returns the number of parsed tests."""
+        return len(self._test_status)
+
+    def PassedTests(self):
+        """Returns list of tests that passed."""
+        return self._TestsByStatus('OK', False, False)
+
+    def FailedTests(self, include_fails=False, include_flaky=False):
+        """Returns list of tests that failed, timed out, or didn't finish.
+
+        This list will be incorrect until the complete log has been processed,
+        because it will show currently running tests as having failed.
+
+        Args:
+            include_fails: If true, all failing tests with FAILS_ in their
+                names will be included. Otherwise, they will only be included
+                if they crashed.
+            include_flaky: If true, all failing tests with FLAKY_ in their
+                names will be included. Otherwise, they will only be included
+                if they crashed.
+        Returns:
+            List of failed tests.
+        """
+        return (self._TestsByStatus('failed', include_fails, include_flaky) +
+                self._TestsByStatus('timeout', include_fails, include_flaky))
+
+    def FailureDescription(self, test):
+        """Returns a list containing the failure description for the given test.
+
+        If the test didn't fail or timeout, returns [].
+        Args:
+            test: Name to test to find failure reason.
+        Returns:
+            List of test name, and failure string.
+        """
+        test_status = self._test_status.get(test, ('', []))
+        return ["%s: " % test] + test_status[1]
+
+    def SuppressionHashes(self):
+        """Returns list of suppression hashes found in the log."""
+        return self._suppressions.keys()
+
+    def Suppression(self, suppression_hash):
+        """Returns a list containing the suppression for a given hash.
+
+        If the suppression hash doesn't exist, returns [].
+
+        Args:
+            suppression_hash: name of hash.
+        Returns:
+            List of suppression for the hash.
+        """
+        return self._suppressions.get(suppression_hash, [])
+
+    def ProcessLogLine(self, line):
+        """This is called once with each line of the test log."""
+
+        # Track line number for error messages.
+        self._line_number += 1
+
+        if not self.master_name:
+            results = self._master_name_re.search(line)
+            if results:
+                self.master_name = results.group(1)
+
+        # Is it a line reporting disabled tests?
+        results = self._disabled.search(line)
+        if results:
+            try:
+                disabled = int(results.group(1))
+            except ValueError:
+                disabled = 0
+            if disabled > 0 and isinstance(self.disabled_tests, int):
+                self.disabled_tests += disabled
+            else:
+                # If we can't parse the line, at least give a heads-up. This is
+                # a safety net for a case that shouldn't happen but isn't a
+                # fatal error.
+                self.disabled_tests = 'some'
+            return
+
+        # Is it a line reporting flaky tests?
+        results = self._flaky.search(line)
+        if results:
+            try:
+                flaky = int(results.group(1))
+            except ValueError:
+                flaky = 0
+            if flaky > 0 and isinstance(self.flaky_tests, int):
+                self.flaky_tests = flaky
+            else:
+                # If we can't parse the line, at least give a heads-up. This is
+                # a safety net for a case that shouldn't happen but isn't a
+                # fatal error.
+                self.flaky_tests = 'some'
+            return
+
+        # Is it the start of a test?
+        results = self._test_start.search(line)
+        if results:
+            test_name = results.group(1)
+            if test_name in self._test_status:
+                self._RecordError(line, 'test started more than once')
+                return
+            if self._current_test:
+                status = self._StatusOfTest(self._current_test)
+                if status in ('OK', 'failed', 'timeout'):
+                    self._RecordError(line, 'start while in status %s' % status)
+                    return
+                if status not in ('failed', 'timeout'):
+                    self._test_status[self._current_test] = (
+                        'failed', self._failure_description)
+            self._test_status[test_name] = ('started', ['Did not complete.'])
+            self._current_test = test_name
+            self._failure_description = []
+            return
+
+        # Is it a test success line?
+        results = self._test_ok.search(line)
+        if results:
+            test_name = results.group(1)
+            status = self._StatusOfTest(test_name)
+            if status != 'started':
+                self._RecordError(line, 'success while in status %s' % status)
+                return
+            self._test_status[test_name] = ('OK', [])
+            self._failure_description = []
+            self._current_test = ''
+            return
+
+        # Is it a test failure line?
+        results = self._test_fail.search(line)
+        if results:
+            test_name = results.group(1)
+            status = self._StatusOfTest(test_name)
+            if status not in ('started', 'failed', 'timeout'):
+                self._RecordError(line, 'failure while in status %s' % status)
+                return
+            # Don't overwrite the failure description when a failing test is
+            # listed a second time in the summary, or if it was already
+            # recorded as timing out.
+            if status not in ('failed', 'timeout'):
+                self._test_status[test_name] = ('failed',
+                                                self._failure_description)
+            self._failure_description = []
+            self._current_test = ''
+            return
+
+        # Is it a test timeout line?
+        results = self._test_timeout.search(line)
+        if results:
+            test_name = results.group(1)
+            status = self._StatusOfTest(test_name)
+            if status not in ('started', 'failed'):
+                self._RecordError(line, 'timeout while in status %s' % status)
+                return
+            self._test_status[test_name] = (
+                'timeout', self._failure_description + ['Killed (timed out).'])
+            self._failure_description = []
+            self._current_test = ''
+            return
+
+        # Is it the start of a new valgrind suppression?
+        results = self._suppression_start.search(line)
+        if results:
+            suppression_hash = results.group(1)
+            if suppression_hash in self._suppressions:
+                self._RecordError(line, 'suppression reported more than once')
+                return
+            self._suppressions[suppression_hash] = []
+            self._current_suppression_hash = suppression_hash
+            self._current_suppression = [line]
+            return
+
+        # Is it the end of a valgrind suppression?
+        results = self._suppression_end.search(line)
+        if results and self._current_suppression_hash:
+            self._current_suppression.append(line)
+            self._suppressions[self._current_suppression_hash] = (
+                self._current_suppression)
+            self._current_suppression_hash = ''
+            self._current_suppression = []
+            return
+
+        # Random line: if we're in a suppression, collect it. Suppressions are
+        # generated after all tests are finished, so this should always belong
+        # to the current suppression hash.
+        if self._current_suppression_hash:
+            self._current_suppression.append(line)
+            return
+
+        # Random line: if we're in a test, collect it for the failure
+        # description. Tests may run simultaneously, so this might be off, but
+        # it's worth a try.
+        if self._current_test:
+            self._failure_description.append(line)