summaryrefslogtreecommitdiffstats
path: root/Tools/Scripts/webkitpy/common/net/buildbot
diff options
context:
space:
mode:
Diffstat (limited to 'Tools/Scripts/webkitpy/common/net/buildbot')
-rw-r--r--Tools/Scripts/webkitpy/common/net/buildbot/__init__.py5
-rw-r--r--Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py463
-rw-r--r--Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py413
3 files changed, 881 insertions, 0 deletions
diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py b/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py
new file mode 100644
index 0000000..631ef6b
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py
@@ -0,0 +1,5 @@
+# Required for Python to search this directory for module files
+
+# We only export public API here.
+# It's unclear if Builder and Build need to be public.
+from .buildbot import BuildBot, Builder, Build
diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py
new file mode 100644
index 0000000..3cb6da5
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py
@@ -0,0 +1,463 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for interacting with WebKit's buildbot
+
+try:
+ import json
+except ImportError:
+ # python 2.5 compatibility
+ import webkitpy.thirdparty.simplejson as json
+
+import operator
+import re
+import urllib
+import urllib2
+
+from webkitpy.common.net.failuremap import FailureMap
+from webkitpy.common.net.layouttestresults import LayoutTestResults
+from webkitpy.common.net.regressionwindow import RegressionWindow
+from webkitpy.common.system.logutils import get_logger
+from webkitpy.thirdparty.autoinstalled.mechanize import Browser
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+_log = get_logger(__file__)
+
+
+class Builder(object):
+ def __init__(self, name, buildbot):
+ self._name = name
+ self._buildbot = buildbot
+ self._builds_cache = {}
+ self._revision_to_build_number = None
+ self._browser = Browser()
+ self._browser.set_handle_robots(False) # The builder pages are excluded by robots.txt
+
+ def name(self):
+ return self._name
+
+ def results_url(self):
+ return "http://%s/results/%s" % (self._buildbot.buildbot_host, self.url_encoded_name())
+
+ def url_encoded_name(self):
+ return urllib.quote(self._name)
+
+ def url(self):
+ return "http://%s/builders/%s" % (self._buildbot.buildbot_host, self.url_encoded_name())
+
+ # This provides a single place to mock
+ def _fetch_build(self, build_number):
+ build_dictionary = self._buildbot._fetch_build_dictionary(self, build_number)
+ if not build_dictionary:
+ return None
+ return Build(self,
+ build_number=int(build_dictionary['number']),
+ revision=int(build_dictionary['sourceStamp']['revision']),
+ is_green=(build_dictionary['results'] == 0) # Undocumented, 0 seems to mean "pass"
+ )
+
+ def build(self, build_number):
+ if not build_number:
+ return None
+ cached_build = self._builds_cache.get(build_number)
+ if cached_build:
+ return cached_build
+
+ build = self._fetch_build(build_number)
+ self._builds_cache[build_number] = build
+ return build
+
+ def force_build(self, username="webkit-patch", comments=None):
+ def predicate(form):
+ try:
+ return form.find_control("username")
+ except Exception, e:
+ return False
+ self._browser.open(self.url())
+ self._browser.select_form(predicate=predicate)
+ self._browser["username"] = username
+ if comments:
+ self._browser["comments"] = comments
+ return self._browser.submit()
+
+ file_name_regexp = re.compile(r"r(?P<revision>\d+) \((?P<build_number>\d+)\)")
+ def _revision_and_build_for_filename(self, filename):
+ # Example: "r47483 (1)/" or "r47483 (1).zip"
+ match = self.file_name_regexp.match(filename)
+ return (int(match.group("revision")), int(match.group("build_number")))
+
+ def _fetch_revision_to_build_map(self):
+ # All _fetch requests go through _buildbot for easier mocking
+ # FIXME: This should use NetworkTransaction's 404 handling instead.
+ try:
+ # FIXME: This method is horribly slow due to the huge network load.
+ # FIXME: This is a poor way to do revision -> build mapping.
+ # Better would be to ask buildbot through some sort of API.
+ print "Loading revision/build list from %s." % self.results_url()
+ print "This may take a while..."
+ result_files = self._buildbot._fetch_twisted_directory_listing(self.results_url())
+ except urllib2.HTTPError, error:
+ if error.code != 404:
+ raise
+ result_files = []
+
+ # This assumes there was only one build per revision, which is false but we don't care for now.
+ return dict([self._revision_and_build_for_filename(file_info["filename"]) for file_info in result_files])
+
+ def _revision_to_build_map(self):
+ if not self._revision_to_build_number:
+ self._revision_to_build_number = self._fetch_revision_to_build_map()
+ return self._revision_to_build_number
+
+ def revision_build_pairs_with_results(self):
+ return self._revision_to_build_map().items()
+
+ # This assumes there can be only one build per revision, which is false, but we don't care for now.
+ def build_for_revision(self, revision, allow_failed_lookups=False):
+ # NOTE: This lookup will fail if that exact revision was never built.
+ build_number = self._revision_to_build_map().get(int(revision))
+ if not build_number:
+ return None
+ build = self.build(build_number)
+ if not build and allow_failed_lookups:
+ # Builds for old revisions with fail to lookup via buildbot's json api.
+ build = Build(self,
+ build_number=build_number,
+ revision=revision,
+ is_green=False,
+ )
+ return build
+
+ def find_regression_window(self, red_build, look_back_limit=30):
+ if not red_build or red_build.is_green():
+ return RegressionWindow(None, None)
+ common_failures = None
+ current_build = red_build
+ build_after_current_build = None
+ look_back_count = 0
+ while current_build:
+ if current_build.is_green():
+ # current_build can't possibly have any failures in common
+ # with red_build because it's green.
+ break
+ results = current_build.layout_test_results()
+ # We treat a lack of results as if all the test failed.
+ # This occurs, for example, when we can't compile at all.
+ if results:
+ failures = set(results.failing_tests())
+ if common_failures == None:
+ common_failures = failures
+ else:
+ common_failures = common_failures.intersection(failures)
+ if not common_failures:
+ # current_build doesn't have any failures in common with
+ # the red build we're worried about. We assume that any
+ # failures in current_build were due to flakiness.
+ break
+ look_back_count += 1
+ if look_back_count > look_back_limit:
+ return RegressionWindow(None, current_build, failing_tests=common_failures)
+ build_after_current_build = current_build
+ current_build = current_build.previous_build()
+ # We must iterate at least once because red_build is red.
+ assert(build_after_current_build)
+ # Current build must either be green or have no failures in common
+ # with red build, so we've found our failure transition.
+ return RegressionWindow(current_build, build_after_current_build, failing_tests=common_failures)
+
+ def find_blameworthy_regression_window(self, red_build_number, look_back_limit=30, avoid_flakey_tests=True):
+ red_build = self.build(red_build_number)
+ regression_window = self.find_regression_window(red_build, look_back_limit)
+ if not regression_window.build_before_failure():
+ return None # We ran off the limit of our search
+ # If avoid_flakey_tests, require at least 2 bad builds before we
+ # suspect a real failure transition.
+ if avoid_flakey_tests and regression_window.failing_build() == red_build:
+ return None
+ return regression_window
+
+
+class Build(object):
+ def __init__(self, builder, build_number, revision, is_green):
+ self._builder = builder
+ self._number = build_number
+ self._revision = revision
+ self._is_green = is_green
+ self._layout_test_results = None
+
+ @staticmethod
+ def build_url(builder, build_number):
+ return "%s/builds/%s" % (builder.url(), build_number)
+
+ def url(self):
+ return self.build_url(self.builder(), self._number)
+
+ def results_url(self):
+ results_directory = "r%s (%s)" % (self.revision(), self._number)
+ return "%s/%s" % (self._builder.results_url(), urllib.quote(results_directory))
+
+ def _fetch_results_html(self):
+ results_html = "%s/results.html" % (self.results_url())
+ # FIXME: This should use NetworkTransaction's 404 handling instead.
+ try:
+ # It seems this can return None if the url redirects and then returns 404.
+ return urllib2.urlopen(results_html)
+ except urllib2.HTTPError, error:
+ if error.code != 404:
+ raise
+
+ def layout_test_results(self):
+ if not self._layout_test_results:
+ # FIXME: This should cache that the result was a 404 and stop hitting the network.
+ self._layout_test_results = LayoutTestResults.results_from_string(self._fetch_results_html())
+ return self._layout_test_results
+
+ def builder(self):
+ return self._builder
+
+ def revision(self):
+ return self._revision
+
+ def is_green(self):
+ return self._is_green
+
+ def previous_build(self):
+ # previous_build() allows callers to avoid assuming build numbers are sequential.
+ # They may not be sequential across all master changes, or when non-trunk builds are made.
+ return self._builder.build(self._number - 1)
+
+
+class BuildBot(object):
+ # FIXME: This should move into some sort of webkit_config.py
+ default_host = "build.webkit.org"
+
+ def __init__(self, host=default_host):
+ self.buildbot_host = host
+ self._builder_by_name = {}
+
+ # If any core builder is red we should not be landing patches. Other
+ # builders should be added to this list once they are known to be
+ # reliable.
+ # See https://bugs.webkit.org/show_bug.cgi?id=33296 and related bugs.
+ self.core_builder_names_regexps = [
+ "SnowLeopard.*Build",
+ "SnowLeopard.*\(Test", # Exclude WebKit2 for now.
+ "Leopard",
+ "Tiger",
+ "Windows.*Build",
+ "GTK.*32",
+ "GTK.*64.*Debug", # Disallow the 64-bit Release bot which is broken.
+ "Qt",
+ "Chromium.*Release$",
+ ]
+
+ def _parse_last_build_cell(self, builder, cell):
+ status_link = cell.find('a')
+ if status_link:
+ # Will be either a revision number or a build number
+ revision_string = status_link.string
+ # If revision_string has non-digits assume it's not a revision number.
+ builder['built_revision'] = int(revision_string) \
+ if not re.match('\D', revision_string) \
+ else None
+
+ # FIXME: We treat slave lost as green even though it is not to
+ # work around the Qts bot being on a broken internet connection.
+ # The real fix is https://bugs.webkit.org/show_bug.cgi?id=37099
+ builder['is_green'] = not re.search('fail', cell.renderContents()) or \
+ not not re.search('lost', cell.renderContents())
+
+ status_link_regexp = r"builders/(?P<builder_name>.*)/builds/(?P<build_number>\d+)"
+ link_match = re.match(status_link_regexp, status_link['href'])
+ builder['build_number'] = int(link_match.group("build_number"))
+ else:
+ # We failed to find a link in the first cell, just give up. This
+ # can happen if a builder is just-added, the first cell will just
+ # be "no build"
+ # Other parts of the code depend on is_green being present.
+ builder['is_green'] = False
+ builder['built_revision'] = None
+ builder['build_number'] = None
+
+ def _parse_current_build_cell(self, builder, cell):
+ activity_lines = cell.renderContents().split("<br />")
+ builder["activity"] = activity_lines[0] # normally "building" or "idle"
+ # The middle lines document how long left for any current builds.
+ match = re.match("(?P<pending_builds>\d) pending", activity_lines[-1])
+ builder["pending_builds"] = int(match.group("pending_builds")) if match else 0
+
+ def _parse_builder_status_from_row(self, status_row):
+ status_cells = status_row.findAll('td')
+ builder = {}
+
+ # First cell is the name
+ name_link = status_cells[0].find('a')
+ builder["name"] = unicode(name_link.string)
+
+ self._parse_last_build_cell(builder, status_cells[1])
+ self._parse_current_build_cell(builder, status_cells[2])
+ return builder
+
+ def _matches_regexps(self, builder_name, name_regexps):
+ for name_regexp in name_regexps:
+ if re.match(name_regexp, builder_name):
+ return True
+ return False
+
+ # FIXME: Should move onto Builder
+ def _is_core_builder(self, builder_name):
+ return self._matches_regexps(builder_name, self.core_builder_names_regexps)
+
+ # FIXME: This method needs to die, but is used by a unit test at the moment.
+ def _builder_statuses_with_names_matching_regexps(self, builder_statuses, name_regexps):
+ return [builder for builder in builder_statuses if self._matches_regexps(builder["name"], name_regexps)]
+
+ def red_core_builders(self):
+ return [builder for builder in self.core_builder_statuses() if not builder["is_green"]]
+
+ def red_core_builders_names(self):
+ return [builder["name"] for builder in self.red_core_builders()]
+
+ def idle_red_core_builders(self):
+ return [builder for builder in self.red_core_builders() if builder["activity"] == "idle"]
+
+ def core_builders_are_green(self):
+ return not self.red_core_builders()
+
+ # FIXME: These _fetch methods should move to a networking class.
+ def _fetch_build_dictionary(self, builder, build_number):
+ try:
+ base = "http://%s" % self.buildbot_host
+ path = urllib.quote("json/builders/%s/builds/%s" % (builder.name(),
+ build_number))
+ url = "%s/%s" % (base, path)
+ jsondata = urllib2.urlopen(url)
+ return json.load(jsondata)
+ except urllib2.URLError, err:
+ build_url = Build.build_url(builder, build_number)
+ _log.error("Error fetching data for %s build %s (%s): %s" % (builder.name(), build_number, build_url, err))
+ return None
+ except ValueError, err:
+ build_url = Build.build_url(builder, build_number)
+ _log.error("Error decoding json data from %s: %s" % (build_url, err))
+ return None
+
+ def _fetch_one_box_per_builder(self):
+ build_status_url = "http://%s/one_box_per_builder" % self.buildbot_host
+ return urllib2.urlopen(build_status_url)
+
+ def _file_cell_text(self, file_cell):
+ """Traverses down through firstChild elements until one containing a string is found, then returns that string"""
+ element = file_cell
+ while element.string is None and element.contents:
+ element = element.contents[0]
+ return element.string
+
+ def _parse_twisted_file_row(self, file_row):
+ string_or_empty = lambda string: unicode(string) if string else u""
+ file_cells = file_row.findAll('td')
+ return {
+ "filename": string_or_empty(self._file_cell_text(file_cells[0])),
+ "size": string_or_empty(self._file_cell_text(file_cells[1])),
+ "type": string_or_empty(self._file_cell_text(file_cells[2])),
+ "encoding": string_or_empty(self._file_cell_text(file_cells[3])),
+ }
+
+ def _parse_twisted_directory_listing(self, page):
+ soup = BeautifulSoup(page)
+ # HACK: Match only table rows with a class to ignore twisted header/footer rows.
+ file_rows = soup.find('table').findAll('tr', {'class': re.compile(r'\b(?:directory|file)\b')})
+ return [self._parse_twisted_file_row(file_row) for file_row in file_rows]
+
+ # FIXME: There should be a better way to get this information directly from twisted.
+ def _fetch_twisted_directory_listing(self, url):
+ return self._parse_twisted_directory_listing(urllib2.urlopen(url))
+
+ def builders(self):
+ return [self.builder_with_name(status["name"]) for status in self.builder_statuses()]
+
+ # This method pulls from /one_box_per_builder as an efficient way to get information about
+ def builder_statuses(self):
+ soup = BeautifulSoup(self._fetch_one_box_per_builder())
+ return [self._parse_builder_status_from_row(status_row) for status_row in soup.find('table').findAll('tr')]
+
+ def core_builder_statuses(self):
+ return [builder for builder in self.builder_statuses() if self._is_core_builder(builder["name"])]
+
+ def builder_with_name(self, name):
+ builder = self._builder_by_name.get(name)
+ if not builder:
+ builder = Builder(name, self)
+ self._builder_by_name[name] = builder
+ return builder
+
+ def failure_map(self, only_core_builders=True):
+ builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
+ failure_map = FailureMap()
+ revision_to_failing_bots = {}
+ for builder_status in builder_statuses:
+ if builder_status["is_green"]:
+ continue
+ builder = self.builder_with_name(builder_status["name"])
+ regression_window = builder.find_blameworthy_regression_window(builder_status["build_number"])
+ if regression_window:
+ failure_map.add_regression_window(builder, regression_window)
+ return failure_map
+
+ # This makes fewer requests than calling Builder.latest_build would. It grabs all builder
+ # statuses in one request using self.builder_statuses (fetching /one_box_per_builder instead of builder pages).
+ def _latest_builds_from_builders(self, only_core_builders=True):
+ builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
+ return [self.builder_with_name(status["name"]).build(status["build_number"]) for status in builder_statuses]
+
+ def _build_at_or_before_revision(self, build, revision):
+ while build:
+ if build.revision() <= revision:
+ return build
+ build = build.previous_build()
+
+ def last_green_revision(self, only_core_builders=True):
+ builds = self._latest_builds_from_builders(only_core_builders)
+ target_revision = builds[0].revision()
+ # An alternate way to do this would be to start at one revision and walk backwards
+ # checking builder.build_for_revision, however build_for_revision is very slow on first load.
+ while True:
+ # Make builds agree on revision
+ builds = [self._build_at_or_before_revision(build, target_revision) for build in builds]
+ if None in builds: # One of the builds failed to load from the server.
+ return None
+ min_revision = min(map(lambda build: build.revision(), builds))
+ if min_revision != target_revision:
+ target_revision = min_revision
+ continue # Builds don't all agree on revision, keep searching
+ # Check to make sure they're all green
+ all_are_green = reduce(operator.and_, map(lambda build: build.is_green(), builds))
+ if not all_are_green:
+ target_revision -= 1
+ continue
+ return min_revision
diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py
new file mode 100644
index 0000000..a10e432
--- /dev/null
+++ b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py
@@ -0,0 +1,413 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.net.layouttestresults import LayoutTestResults
+from webkitpy.common.net.buildbot import BuildBot, Builder, Build
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+
+class BuilderTest(unittest.TestCase):
+ def _install_fetch_build(self, failure):
+ def _mock_fetch_build(build_number):
+ build = Build(
+ builder=self.builder,
+ build_number=build_number,
+ revision=build_number + 1000,
+ is_green=build_number < 4
+ )
+ parsed_results = {LayoutTestResults.fail_key: failure(build_number)}
+ build._layout_test_results = LayoutTestResults(parsed_results)
+ return build
+ self.builder._fetch_build = _mock_fetch_build
+
+ def setUp(self):
+ self.buildbot = BuildBot()
+ self.builder = Builder(u"Test Builder \u2661", self.buildbot)
+ self._install_fetch_build(lambda build_number: ["test1", "test2"])
+
+ def test_find_regression_window(self):
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure().revision(), 1003)
+ self.assertEqual(regression_window.failing_build().revision(), 1004)
+
+ regression_window = self.builder.find_regression_window(self.builder.build(10), look_back_limit=2)
+ self.assertEqual(regression_window.build_before_failure(), None)
+ self.assertEqual(regression_window.failing_build().revision(), 1008)
+
+ def test_none_build(self):
+ self.builder._fetch_build = lambda build_number: None
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure(), None)
+ self.assertEqual(regression_window.failing_build(), None)
+
+ def test_flaky_tests(self):
+ self._install_fetch_build(lambda build_number: ["test1"] if build_number % 2 else ["test2"])
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure().revision(), 1009)
+ self.assertEqual(regression_window.failing_build().revision(), 1010)
+
+ def test_failure_and_flaky(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"])
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure().revision(), 1003)
+ self.assertEqual(regression_window.failing_build().revision(), 1004)
+
+ def test_no_results(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"])
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure().revision(), 1003)
+ self.assertEqual(regression_window.failing_build().revision(), 1004)
+
+ def test_failure_after_flaky(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number > 6 else ["test3"])
+ regression_window = self.builder.find_regression_window(self.builder.build(10))
+ self.assertEqual(regression_window.build_before_failure().revision(), 1006)
+ self.assertEqual(regression_window.failing_build().revision(), 1007)
+
+ def test_find_blameworthy_regression_window(self):
+ self.assertEqual(self.builder.find_blameworthy_regression_window(10).revisions(), [1004])
+ self.assertEqual(self.builder.find_blameworthy_regression_window(10, look_back_limit=2), None)
+ # Flakey test avoidance requires at least 2 red builds:
+ self.assertEqual(self.builder.find_blameworthy_regression_window(4), None)
+ self.assertEqual(self.builder.find_blameworthy_regression_window(4, avoid_flakey_tests=False).revisions(), [1004])
+ # Green builder:
+ self.assertEqual(self.builder.find_blameworthy_regression_window(3), None)
+
+ def test_build_caching(self):
+ self.assertEqual(self.builder.build(10), self.builder.build(10))
+
+ def test_build_and_revision_for_filename(self):
+ expectations = {
+ "r47483 (1)/" : (47483, 1),
+ "r47483 (1).zip" : (47483, 1),
+ }
+ for filename, revision_and_build in expectations.items():
+ self.assertEqual(self.builder._revision_and_build_for_filename(filename), revision_and_build)
+
+
+class BuildTest(unittest.TestCase):
+ def test_layout_test_results(self):
+ build = Build(None, None, None, None)
+ build._fetch_results_html = lambda: None
+ # Test that layout_test_results() returns None if the fetch fails.
+ self.assertEqual(build.layout_test_results(), None)
+
+
+class BuildBotTest(unittest.TestCase):
+
+ _example_one_box_status = '''
+ <table>
+ <tr>
+ <td class="box"><a href="builders/Windows%20Debug%20%28Tests%29">Windows Debug (Tests)</a></td>
+ <td align="center" class="LastBuild box success"><a href="builders/Windows%20Debug%20%28Tests%29/builds/3693">47380</a><br />build<br />successful</td>
+ <td align="center" class="Activity building">building<br />ETA in<br />~ 14 mins<br />at 13:40</td>
+ <tr>
+ <td class="box"><a href="builders/SnowLeopard%20Intel%20Release">SnowLeopard Intel Release</a></td>
+ <td class="LastBuild box" >no build</td>
+ <td align="center" class="Activity building">building<br />< 1 min</td>
+ <tr>
+ <td class="box"><a href="builders/Qt%20Linux%20Release">Qt Linux Release</a></td>
+ <td align="center" class="LastBuild box failure"><a href="builders/Qt%20Linux%20Release/builds/654">47383</a><br />failed<br />compile-webkit</td>
+ <td align="center" class="Activity idle">idle<br />3 pending</td>
+ <tr>
+ <td class="box"><a href="builders/Qt%20Windows%2032-bit%20Debug">Qt Windows 32-bit Debug</a></td>
+ <td align="center" class="LastBuild box failure"><a href="builders/Qt%20Windows%2032-bit%20Debug/builds/2090">60563</a><br />failed<br />failed<br />slave<br />lost</td>
+ <td align="center" class="Activity building">building<br />ETA in<br />~ 5 mins<br />at 08:25</td>
+ </table>
+'''
+ _expected_example_one_box_parsings = [
+ {
+ 'is_green': True,
+ 'build_number' : 3693,
+ 'name': u'Windows Debug (Tests)',
+ 'built_revision': 47380,
+ 'activity': 'building',
+ 'pending_builds': 0,
+ },
+ {
+ 'is_green': False,
+ 'build_number' : None,
+ 'name': u'SnowLeopard Intel Release',
+ 'built_revision': None,
+ 'activity': 'building',
+ 'pending_builds': 0,
+ },
+ {
+ 'is_green': False,
+ 'build_number' : 654,
+ 'name': u'Qt Linux Release',
+ 'built_revision': 47383,
+ 'activity': 'idle',
+ 'pending_builds': 3,
+ },
+ {
+ 'is_green': True,
+ 'build_number' : 2090,
+ 'name': u'Qt Windows 32-bit Debug',
+ 'built_revision': 60563,
+ 'activity': 'building',
+ 'pending_builds': 0,
+ },
+ ]
+
+ def test_status_parsing(self):
+ buildbot = BuildBot()
+
+ soup = BeautifulSoup(self._example_one_box_status)
+ status_table = soup.find("table")
+ input_rows = status_table.findAll('tr')
+
+ for x in range(len(input_rows)):
+ status_row = input_rows[x]
+ expected_parsing = self._expected_example_one_box_parsings[x]
+
+ builder = buildbot._parse_builder_status_from_row(status_row)
+
+ # Make sure we aren't parsing more or less than we expect
+ self.assertEquals(builder.keys(), expected_parsing.keys())
+
+ for key, expected_value in expected_parsing.items():
+ self.assertEquals(builder[key], expected_value, ("Builder %d parse failure for key: %s: Actual='%s' Expected='%s'" % (x, key, builder[key], expected_value)))
+
+ def test_core_builder_methods(self):
+ buildbot = BuildBot()
+
+ # Override builder_statuses function to not touch the network.
+ def example_builder_statuses(): # We could use instancemethod() to bind 'self' but we don't need to.
+ return BuildBotTest._expected_example_one_box_parsings
+ buildbot.builder_statuses = example_builder_statuses
+
+ buildbot.core_builder_names_regexps = [ 'Leopard', "Windows.*Build" ]
+ self.assertEquals(buildbot.red_core_builders_names(), [])
+ self.assertTrue(buildbot.core_builders_are_green())
+
+ buildbot.core_builder_names_regexps = [ 'SnowLeopard', 'Qt' ]
+ self.assertEquals(buildbot.red_core_builders_names(), [ u'SnowLeopard Intel Release', u'Qt Linux Release' ])
+ self.assertFalse(buildbot.core_builders_are_green())
+
+ def test_builder_name_regexps(self):
+ buildbot = BuildBot()
+
+ # For complete testing, this list should match the list of builders at build.webkit.org:
+ example_builders = [
+ {'name': u'Tiger Intel Release', },
+ {'name': u'Leopard Intel Release (Build)', },
+ {'name': u'Leopard Intel Release (Tests)', },
+ {'name': u'Leopard Intel Debug (Build)', },
+ {'name': u'Leopard Intel Debug (Tests)', },
+ {'name': u'SnowLeopard Intel Release (Build)', },
+ {'name': u'SnowLeopard Intel Release (Tests)', },
+ {'name': u'SnowLeopard Intel Release (WebKit2 Tests)', },
+ {'name': u'SnowLeopard Intel Leaks', },
+ {'name': u'Windows Release (Build)', },
+ {'name': u'Windows Release (Tests)', },
+ {'name': u'Windows Debug (Build)', },
+ {'name': u'Windows Debug (Tests)', },
+ {'name': u'GTK Linux 32-bit Release', },
+ {'name': u'GTK Linux 32-bit Debug', },
+ {'name': u'GTK Linux 64-bit Debug', },
+ {'name': u'GTK Linux 64-bit Release', },
+ {'name': u'Qt Linux Release', },
+ {'name': u'Qt Linux Release minimal', },
+ {'name': u'Qt Linux ARMv5 Release', },
+ {'name': u'Qt Linux ARMv7 Release', },
+ {'name': u'Qt Windows 32-bit Release', },
+ {'name': u'Qt Windows 32-bit Debug', },
+ {'name': u'Chromium Linux Release', },
+ {'name': u'Chromium Mac Release', },
+ {'name': u'Chromium Win Release', },
+ {'name': u'Chromium Linux Release (Tests)', },
+ {'name': u'Chromium Mac Release (Tests)', },
+ {'name': u'Chromium Win Release (Tests)', },
+ {'name': u'New run-webkit-tests', },
+ ]
+ name_regexps = [
+ "SnowLeopard.*Build",
+ "SnowLeopard.*\(Test",
+ "Leopard",
+ "Tiger",
+ "Windows.*Build",
+ "GTK.*32",
+ "GTK.*64.*Debug", # Disallow the 64-bit Release bot which is broken.
+ "Qt",
+ "Chromium.*Release$",
+ ]
+ expected_builders = [
+ {'name': u'Tiger Intel Release', },
+ {'name': u'Leopard Intel Release (Build)', },
+ {'name': u'Leopard Intel Release (Tests)', },
+ {'name': u'Leopard Intel Debug (Build)', },
+ {'name': u'Leopard Intel Debug (Tests)', },
+ {'name': u'SnowLeopard Intel Release (Build)', },
+ {'name': u'SnowLeopard Intel Release (Tests)', },
+ {'name': u'Windows Release (Build)', },
+ {'name': u'Windows Debug (Build)', },
+ {'name': u'GTK Linux 32-bit Release', },
+ {'name': u'GTK Linux 32-bit Debug', },
+ {'name': u'GTK Linux 64-bit Debug', },
+ {'name': u'Qt Linux Release', },
+ {'name': u'Qt Linux Release minimal', },
+ {'name': u'Qt Linux ARMv5 Release', },
+ {'name': u'Qt Linux ARMv7 Release', },
+ {'name': u'Qt Windows 32-bit Release', },
+ {'name': u'Qt Windows 32-bit Debug', },
+ {'name': u'Chromium Linux Release', },
+ {'name': u'Chromium Mac Release', },
+ {'name': u'Chromium Win Release', },
+ ]
+
+ # This test should probably be updated if the default regexp list changes
+ self.assertEquals(buildbot.core_builder_names_regexps, name_regexps)
+
+ builders = buildbot._builder_statuses_with_names_matching_regexps(example_builders, name_regexps)
+ self.assertEquals(builders, expected_builders)
+
+ def test_builder_with_name(self):
+ buildbot = BuildBot()
+
+ builder = buildbot.builder_with_name("Test Builder")
+ self.assertEqual(builder.name(), "Test Builder")
+ self.assertEqual(builder.url(), "http://build.webkit.org/builders/Test%20Builder")
+ self.assertEqual(builder.url_encoded_name(), "Test%20Builder")
+ self.assertEqual(builder.results_url(), "http://build.webkit.org/results/Test%20Builder")
+
+ # Override _fetch_build_dictionary function to not touch the network.
+ def mock_fetch_build_dictionary(self, build_number):
+ build_dictionary = {
+ "sourceStamp": {
+ "revision" : 2 * build_number,
+ },
+ "number" : int(build_number),
+ "results" : build_number % 2, # 0 means pass
+ }
+ return build_dictionary
+ buildbot._fetch_build_dictionary = mock_fetch_build_dictionary
+
+ build = builder.build(10)
+ self.assertEqual(build.builder(), builder)
+ self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/10")
+ self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r20%20%2810%29")
+ self.assertEqual(build.revision(), 20)
+ self.assertEqual(build.is_green(), True)
+
+ build = build.previous_build()
+ self.assertEqual(build.builder(), builder)
+ self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/9")
+ self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r18%20%289%29")
+ self.assertEqual(build.revision(), 18)
+ self.assertEqual(build.is_green(), False)
+
+ self.assertEqual(builder.build(None), None)
+
+ _example_directory_listing = '''
+<h1>Directory listing for /results/SnowLeopard Intel Leaks/</h1>
+
+<table>
+ <tr class="alt">
+ <th>Filename</th>
+ <th>Size</th>
+ <th>Content type</th>
+ <th>Content encoding</th>
+ </tr>
+<tr class="directory ">
+ <td><a href="r47483%20%281%29/"><b>r47483 (1)/</b></a></td>
+ <td><b></b></td>
+ <td><b>[Directory]</b></td>
+ <td><b></b></td>
+</tr>
+<tr class="file alt">
+ <td><a href="r47484%20%282%29.zip">r47484 (2).zip</a></td>
+ <td>89K</td>
+ <td>[application/zip]</td>
+ <td></td>
+</tr>
+'''
+ _expected_files = [
+ {
+ "filename" : "r47483 (1)/",
+ "size" : "",
+ "type" : "[Directory]",
+ "encoding" : "",
+ },
+ {
+ "filename" : "r47484 (2).zip",
+ "size" : "89K",
+ "type" : "[application/zip]",
+ "encoding" : "",
+ },
+ ]
+
+ def test_parse_build_to_revision_map(self):
+ buildbot = BuildBot()
+ files = buildbot._parse_twisted_directory_listing(self._example_directory_listing)
+ self.assertEqual(self._expected_files, files)
+
+ # Revision, is_green
+ # Ordered from newest (highest number) to oldest.
+ fake_builder1 = [
+ [2, False],
+ [1, True],
+ ]
+ fake_builder2 = [
+ [2, False],
+ [1, True],
+ ]
+ fake_builders = [
+ fake_builder1,
+ fake_builder2,
+ ]
+ def _build_from_fake(self, fake_builder, index):
+ if index >= len(fake_builder):
+ return None
+ fake_build = fake_builder[index]
+ build = Build(
+ builder=fake_builder,
+ build_number=index,
+ revision=fake_build[0],
+ is_green=fake_build[1],
+ )
+ def mock_previous_build():
+ return self._build_from_fake(fake_builder, index + 1)
+ build.previous_build = mock_previous_build
+ return build
+
+ def _fake_builds_at_index(self, index):
+ return [self._build_from_fake(builder, index) for builder in self.fake_builders]
+
+ def test_last_green_revision(self):
+ buildbot = BuildBot()
+ def mock_builds_from_builders(only_core_builders):
+ return self._fake_builds_at_index(0)
+ buildbot._latest_builds_from_builders = mock_builds_from_builders
+ self.assertEqual(buildbot.last_green_revision(), 1)
+
+
+if __name__ == '__main__':
+ unittest.main()