Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(525)

Unified Diff: infra/scripts/legacy/scripts/slave/runtest.py

Issue 1213433006: Fork runtest.py and everything it needs src-side for easier hacking (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: runisolatedtest.py Created 5 years, 6 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View side-by-side diff with in-line comments
Download patch
Index: infra/scripts/legacy/scripts/slave/runtest.py
diff --git a/infra/scripts/legacy/scripts/slave/runtest.py b/infra/scripts/legacy/scripts/slave/runtest.py
new file mode 100755
index 0000000000000000000000000000000000000000..75c3366a2b37976180611265a21596f00bfed51a
--- /dev/null
+++ b/infra/scripts/legacy/scripts/slave/runtest.py
@@ -0,0 +1,1948 @@
+#!/usr/bin/env python
+# Copyright (c) 2012 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.
+
+"""A tool used to run a Chrome test executable and process the output.
+
+This script is used by the buildbot slaves. It must be run from the outer
+build directory, e.g. chrome-release/build/.
+
+For a list of command-line options, call this script with '--help'.
+"""
+
+import ast
+import copy
+import datetime
+import exceptions
+import gzip
+import hashlib
+import json
+import logging
+import optparse
+import os
+import re
+import stat
+import subprocess
+import sys
+import tempfile
+
+# The following note was added in 2010 by nsylvain:
+#
+# sys.path needs to be modified here because python2.6 automatically adds the
+# system "google" module (/usr/lib/pymodules/python2.6/google) to sys.modules
+# when we import "chromium_config" (I don't know why it does this). This causes
+# the import of our local "google.*" modules to fail because python seems to
+# only look for a system "google.*", even if our path is in sys.path before
+# importing "google.*". If we modify sys.path here, before importing
+# "chromium_config", python2.6 properly uses our path to find our "google.*"
+# (even though it still automatically adds the system "google" module to
+# sys.modules, and probably should still be using that to resolve "google.*",
+# which I really don't understand).
+sys.path.insert(0, os.path.abspath('src/tools/python'))
+
+from common import chromium_utils
+from common import gtest_utils
+
+# TODO(crbug.com/403564). We almost certainly shouldn't be importing this.
+import config
+
+from slave import annotation_utils
+from slave import build_directory
+from slave import crash_utils
+from slave import gtest_slave_utils
+from slave import performance_log_processor
+from slave import results_dashboard
+from slave import slave_utils
+from slave import telemetry_utils
+from slave import xvfb
+
+USAGE = '%s [options] test.exe [test args]' % os.path.basename(sys.argv[0])
+
+CHROME_SANDBOX_PATH = '/opt/chromium/chrome_sandbox'
+
+# Directory to write JSON for test results into.
+DEST_DIR = 'gtest_results'
+
+# Names of httpd configuration file under different platforms.
+HTTPD_CONF = {
+ 'linux': 'httpd2_linux.conf',
+ 'mac': 'httpd2_mac.conf',
+ 'win': 'httpd.conf'
+}
+# Regex matching git comment lines containing svn revision info.
+GIT_SVN_ID_RE = re.compile(r'^git-svn-id: .*@([0-9]+) .*$')
+# Regex for the master branch commit position.
+GIT_CR_POS_RE = re.compile(r'^Cr-Commit-Position: refs/heads/master@{#(\d+)}$')
+
+# The directory that this script is in.
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+LOG_PROCESSOR_CLASSES = {
+ 'gtest': gtest_utils.GTestLogParser,
+ 'graphing': performance_log_processor.GraphingLogProcessor,
+ 'pagecycler': performance_log_processor.GraphingPageCyclerLogProcessor,
+}
+
+
+def _GetTempCount():
+ """Returns the number of files and directories inside the temporary dir."""
+ return len(os.listdir(tempfile.gettempdir()))
+
+
+def _LaunchDBus():
+ """Launches DBus to work around a bug in GLib.
+
+ Works around a bug in GLib where it performs operations which aren't
+ async-signal-safe (in particular, memory allocations) between fork and exec
+ when it spawns subprocesses. This causes threads inside Chrome's browser and
+ utility processes to get stuck, and this harness to hang waiting for those
+ processes, which will never terminate. This doesn't happen on users'
+ machines, because they have an active desktop session and the
+ DBUS_SESSION_BUS_ADDRESS environment variable set, but it does happen on the
+ bots. See crbug.com/309093 for more details.
+
+ Returns:
+ True if it actually spawned DBus.
+ """
+ import platform
+ if (platform.uname()[0].lower() == 'linux' and
+ 'DBUS_SESSION_BUS_ADDRESS' not in os.environ):
+ try:
+ print 'DBUS_SESSION_BUS_ADDRESS env var not found, starting dbus-launch'
+ dbus_output = subprocess.check_output(['dbus-launch']).split('\n')
+ for line in dbus_output:
+ m = re.match(r'([^=]+)\=(.+)', line)
+ if m:
+ os.environ[m.group(1)] = m.group(2)
+ print ' setting %s to %s' % (m.group(1), m.group(2))
+ return True
+ except (subprocess.CalledProcessError, OSError) as e:
+ print 'Exception while running dbus_launch: %s' % e
+ return False
+
+
+def _ShutdownDBus():
+ """Manually kills the previously-launched DBus daemon.
+
+ It appears that passing --exit-with-session to dbus-launch in
+ _LaunchDBus(), above, doesn't cause the launched dbus-daemon to shut
+ down properly. Manually kill the sub-process using the PID it gave
+ us at launch time.
+
+ This function is called when the flag --spawn-dbus is given, and if
+ _LaunchDBus(), above, actually spawned the dbus-daemon.
+ """
+ import signal
+ if 'DBUS_SESSION_BUS_PID' in os.environ:
+ dbus_pid = os.environ['DBUS_SESSION_BUS_PID']
+ try:
+ os.kill(int(dbus_pid), signal.SIGTERM)
+ print ' killed dbus-daemon with PID %s' % dbus_pid
+ except OSError as e:
+ print ' error killing dbus-daemon with PID %s: %s' % (dbus_pid, e)
+ # Try to clean up any stray DBUS_SESSION_BUS_ADDRESS environment
+ # variable too. Some of the bots seem to re-invoke runtest.py in a
+ # way that this variable sticks around from run to run.
+ if 'DBUS_SESSION_BUS_ADDRESS' in os.environ:
+ del os.environ['DBUS_SESSION_BUS_ADDRESS']
+ print ' cleared DBUS_SESSION_BUS_ADDRESS environment variable'
+
+
+def _RunGTestCommand(
+ options, command, extra_env, log_processor=None, pipes=None):
+ """Runs a test, printing and possibly processing the output.
+
+ Args:
+ options: Options passed for this invocation of runtest.py.
+ command: A list of strings in a command (the command and its arguments).
+ extra_env: A dictionary of extra environment variables to set.
+ log_processor: A log processor instance which has the ProcessLine method.
+ pipes: A list of command string lists which the output will be piped to.
+
+ Returns:
+ The process return code.
+ """
+ env = os.environ.copy()
+ if extra_env:
+ print 'Additional test environment:'
+ for k, v in sorted(extra_env.items()):
+ print ' %s=%s' % (k, v)
+ env.update(extra_env or {})
+
+ # Trigger bot mode (test retries, redirection of stdio, possibly faster,
+ # etc.) - using an environment variable instead of command-line flags because
+ # some internal waterfalls run this (_RunGTestCommand) for totally non-gtest
+ # code.
+ # TODO(phajdan.jr): Clean this up when internal waterfalls are fixed.
+ env.update({'CHROMIUM_TEST_LAUNCHER_BOT_MODE': '1'})
+
+ log_processors = {}
+ if log_processor:
+ log_processors[log_processor.__class__.__name__] = log_processor
+
+ if (not 'GTestLogParser' in log_processors and
+ options.log_processor_output_file):
+ log_processors['GTestLogParser'] = gtest_utils.GTestLogParser()
+
+ def _ProcessLine(line):
+ for current_log_processor in log_processors.values():
+ current_log_processor.ProcessLine(line)
+
+ result = chromium_utils.RunCommand(
+ command, pipes=pipes, parser_func=_ProcessLine, env=env)
+
+ if options.log_processor_output_file:
+ _WriteLogProcessorResultsToOutput(
+ log_processors['GTestLogParser'], options.log_processor_output_file)
+
+ return result
+
+
+def _GetMaster():
+ """Return the master name for the current host."""
+ return chromium_utils.GetActiveMaster()
+
+
+def _GetMasterString(master):
+ """Returns a message describing what the master is."""
+ return '[Running for master: "%s"]' % master
+
+
+def _GetGitCommitPositionFromLog(log):
+ """Returns either the commit position or svn rev from a git log."""
+ # Parse from the bottom up, in case the commit message embeds the message
+ # from a different commit (e.g., for a revert).
+ for r in [GIT_CR_POS_RE, GIT_SVN_ID_RE]:
+ for line in reversed(log.splitlines()):
+ m = r.match(line.strip())
+ if m:
+ return m.group(1)
+ return None
+
+
+def _GetGitCommitPosition(dir_path):
+ """Extracts the commit position or svn revision number of the HEAD commit."""
+ git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
+ p = subprocess.Popen(
+ [git_exe, 'log', '-n', '1', '--pretty=format:%B', 'HEAD'],
+ cwd=dir_path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ (log, _) = p.communicate()
+ if p.returncode != 0:
+ return None
+ return _GetGitCommitPositionFromLog(log)
+
+
+def _IsGitDirectory(dir_path):
+ """Checks whether the given directory is in a git repository.
+
+ Args:
+ dir_path: The directory path to be tested.
+
+ Returns:
+ True if given directory is in a git repository, False otherwise.
+ """
+ git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
+ with open(os.devnull, 'w') as devnull:
+ p = subprocess.Popen([git_exe, 'rev-parse', '--git-dir'],
+ cwd=dir_path, stdout=devnull, stderr=devnull)
+ return p.wait() == 0
+
+
+def _GetRevision(in_directory):
+ """Returns the SVN revision, git commit position, or git hash.
+
+ Args:
+ in_directory: A directory in the repository to be checked.
+
+ Returns:
+ An SVN revision as a string if the given directory is in a SVN repository,
+ or a git commit position number, or if that's not available, a git hash.
+ If all of that fails, an empty string is returned.
+ """
+ import xml.dom.minidom
+ if not os.path.exists(os.path.join(in_directory, '.svn')):
+ if _IsGitDirectory(in_directory):
+ svn_rev = _GetGitCommitPosition(in_directory)
+ if svn_rev:
+ return svn_rev
+ return _GetGitRevision(in_directory)
+ else:
+ return ''
+
+ # Note: Not thread safe: http://bugs.python.org/issue2320
+ output = subprocess.Popen(['svn', 'info', '--xml'],
+ cwd=in_directory,
+ shell=(sys.platform == 'win32'),
+ stdout=subprocess.PIPE).communicate()[0]
+ try:
+ dom = xml.dom.minidom.parseString(output)
+ return dom.getElementsByTagName('entry')[0].getAttribute('revision')
+ except xml.parsers.expat.ExpatError:
+ return ''
+ return ''
+
+
+def _GetGitRevision(in_directory):
+ """Returns the git hash tag for the given directory.
+
+ Args:
+ in_directory: The directory where git is to be run.
+
+ Returns:
+ The git SHA1 hash string.
+ """
+ git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
+ p = subprocess.Popen(
+ [git_exe, 'rev-parse', 'HEAD'],
+ cwd=in_directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ (stdout, _) = p.communicate()
+ return stdout.strip()
+
+
+def _GenerateJSONForTestResults(options, log_processor):
+ """Generates or updates a JSON file from the gtest results XML and upload the
+ file to the archive server.
+
+ The archived JSON file will be placed at:
+ www-dir/DEST_DIR/buildname/testname/results.json
+ on the archive server. NOTE: This will be deprecated.
+
+ Args:
+ options: command-line options that are supposed to have build_dir,
+ results_directory, builder_name, build_name and test_output_xml values.
+ log_processor: An instance of PerformanceLogProcessor or similar class.
+
+ Returns:
+ True upon success, False upon failure.
+ """
+ results_map = None
+ try:
+ if (os.path.exists(options.test_output_xml) and
+ not _UsingGtestJson(options)):
+ results_map = gtest_slave_utils.GetResultsMapFromXML(
+ options.test_output_xml)
+ else:
+ if _UsingGtestJson(options):
+ sys.stderr.write('using JSON summary output instead of gtest XML\n')
+ else:
+ sys.stderr.write(
+ ('"%s" \\ "%s" doesn\'t exist: Unable to generate JSON from XML, '
+ 'using log output.\n') % (os.getcwd(), options.test_output_xml))
+ # The file did not get generated. See if we can generate a results map
+ # from the log output.
+ results_map = gtest_slave_utils.GetResultsMap(log_processor)
+ except Exception as e:
+ # This error will be caught by the following 'not results_map' statement.
+ print 'Error: ', e
+
+ if not results_map:
+ print 'No data was available to update the JSON results'
+ # Consider this non-fatal.
+ return True
+
+ build_dir = os.path.abspath(options.build_dir)
+ slave_name = options.builder_name or slave_utils.SlaveBuildName(build_dir)
+
+ generate_json_options = copy.copy(options)
+ generate_json_options.build_name = slave_name
+ generate_json_options.input_results_xml = options.test_output_xml
+ generate_json_options.builder_base_url = '%s/%s/%s/%s' % (
+ config.Master.archive_url, DEST_DIR, slave_name, options.test_type)
+ generate_json_options.master_name = options.master_class_name or _GetMaster()
+ generate_json_options.test_results_server = config.Master.test_results_server
+
+ print _GetMasterString(generate_json_options.master_name)
+
+ generator = None
+
+ try:
+ if options.revision:
+ generate_json_options.chrome_revision = options.revision
+ else:
+ chrome_dir = chromium_utils.FindUpwardParent(build_dir, 'third_party')
+ generate_json_options.chrome_revision = _GetRevision(chrome_dir)
+
+ if options.webkit_revision:
+ generate_json_options.webkit_revision = options.webkit_revision
+ else:
+ webkit_dir = chromium_utils.FindUpward(
+ build_dir, 'third_party', 'WebKit', 'Source')
+ generate_json_options.webkit_revision = _GetRevision(webkit_dir)
+
+ # Generate results JSON file and upload it to the appspot server.
+ generator = gtest_slave_utils.GenerateJSONResults(
+ results_map, generate_json_options)
+
+ except Exception as e:
+ print 'Unexpected error while generating JSON: %s' % e
+ sys.excepthook(*sys.exc_info())
+ return False
+
+ # The code can throw all sorts of exceptions, including
+ # slave.gtest.networktransaction.NetworkTimeout so just trap everything.
+ # Earlier versions of this code ignored network errors, so until a
+ # retry mechanism is added, continue to do so rather than reporting
+ # an error.
+ try:
+ # Upload results JSON file to the appspot server.
+ gtest_slave_utils.UploadJSONResults(generator)
+ except Exception as e:
+ # Consider this non-fatal for the moment.
+ print 'Unexpected error while uploading JSON: %s' % e
+ sys.excepthook(*sys.exc_info())
+
+ return True
+
+
+def _BuildTestBinaryCommand(_build_dir, test_exe_path, options):
+ """Builds a command to run a test binary.
+
+ Args:
+ build_dir: Path to the tools/build directory.
+ test_exe_path: Path to test command binary.
+ options: Options passed for this invocation of runtest.py.
+
+ Returns:
+ A command, represented as a list of command parts.
+ """
+ command = [
+ test_exe_path,
+ ]
+
+ if options.annotate == 'gtest':
+ command.append('--test-launcher-bot-mode')
+
+ if options.total_shards and options.shard_index:
+ command.extend([
+ '--test-launcher-total-shards=%d' % options.total_shards,
+ '--test-launcher-shard-index=%d' % (options.shard_index - 1)])
+
+ return command
+
+
+def _UsingGtestJson(options):
+ """Returns True if we're using GTest JSON summary."""
+ return (options.annotate == 'gtest' and
+ not options.run_python_script and
+ not options.run_shell_script)
+
+
+def _ListLogProcessors(selection):
+ """Prints a list of available log processor classes iff the input is 'list'.
+
+ Args:
+ selection: A log processor name, or the string "list".
+
+ Returns:
+ True if a list was printed, False otherwise.
+ """
+ shouldlist = selection and selection == 'list'
+ if shouldlist:
+ print
+ print 'Available log processors:'
+ for p in LOG_PROCESSOR_CLASSES:
+ print ' ', p, LOG_PROCESSOR_CLASSES[p].__name__
+
+ return shouldlist
+
+
+def _SelectLogProcessor(options, is_telemetry):
+ """Returns a log processor class based on the command line options.
+
+ Args:
+ options: Command-line options (from OptionParser).
+ is_telemetry: bool for whether to create a telemetry log processor.
+
+ Returns:
+ A log processor class, or None.
+ """
+ if _UsingGtestJson(options):
+ return gtest_utils.GTestJSONParser
+
+ if is_telemetry:
+ return telemetry_utils.TelemetryResultsProcessor
+
+ if options.annotate:
+ if options.annotate in LOG_PROCESSOR_CLASSES:
+ if options.generate_json_file and options.annotate != 'gtest':
+ raise NotImplementedError('"%s" doesn\'t make sense with '
+ 'options.generate_json_file.')
+ else:
+ return LOG_PROCESSOR_CLASSES[options.annotate]
+ else:
+ raise KeyError('"%s" is not a valid GTest parser!' % options.annotate)
+ elif options.generate_json_file:
+ return LOG_PROCESSOR_CLASSES['gtest']
+
+ return None
+
+
+def _GetCommitPos(build_properties):
+ """Extracts the commit position from the build properties, if its there."""
+ if 'got_revision_cp' not in build_properties:
+ return None
+ commit_pos = build_properties['got_revision_cp']
+ return int(re.search(r'{#(\d+)}', commit_pos).group(1))
+
+
+def _GetMainRevision(options):
+ """Return revision to use as the numerical x-value in the perf dashboard.
+
+ This will be used as the value of "rev" in the data passed to
+ results_dashboard.SendResults.
+
+ In order or priority, this function could return:
+ 1. The value of the --revision flag (IF it can be parsed as an int).
+ 2. The value of "got_revision_cp" in build properties.
+ 3. An SVN number, git commit position, or git commit hash.
+ """
+ if options.revision and options.revision.isdigit():
+ return options.revision
+ commit_pos_num = _GetCommitPos(options.build_properties)
+ if commit_pos_num is not None:
+ return commit_pos_num
+ # TODO(sullivan,qyearsley): Don't fall back to _GetRevision if it returns
+ # a git commit, since this should be a numerical revision. Instead, abort
+ # and fail.
+ return _GetRevision(os.path.dirname(os.path.abspath(options.build_dir)))
+
+
+def _GetBlinkRevision(options):
+ if options.webkit_revision:
+ webkit_revision = options.webkit_revision
+ else:
+ try:
+ webkit_dir = chromium_utils.FindUpward(
+ os.path.abspath(options.build_dir), 'third_party', 'WebKit', 'Source')
+ webkit_revision = _GetRevision(webkit_dir)
+ except Exception:
+ webkit_revision = None
+ return webkit_revision
+
+
+def _GetTelemetryRevisions(options):
+ """Fills in the same revisions fields that process_log_utils does."""
+
+ versions = {}
+ versions['rev'] = _GetMainRevision(options)
+ versions['webkit_rev'] = _GetBlinkRevision(options)
+ versions['webrtc_rev'] = options.build_properties.get('got_webrtc_revision')
+ versions['v8_rev'] = options.build_properties.get('got_v8_revision')
+ versions['ver'] = options.build_properties.get('version')
+ versions['git_revision'] = options.build_properties.get('git_revision')
+ # There are a lot of "bad" revisions to check for, so clean them all up here.
+ for key in versions.keys():
+ if not versions[key] or versions[key] == 'undefined':
+ del versions[key]
+ return versions
+
+
+def _CreateLogProcessor(log_processor_class, options, telemetry_info):
+ """Creates a log processor instance.
+
+ Args:
+ log_processor_class: A subclass of PerformanceLogProcessor or similar class.
+ options: Command-line options (from OptionParser).
+ telemetry_info: dict of info for run_benchmark runs.
+
+ Returns:
+ An instance of a log processor class, or None.
+ """
+ if not log_processor_class:
+ return None
+
+ if log_processor_class.__name__ == 'TelemetryResultsProcessor':
+ tracker_obj = log_processor_class(
+ telemetry_info['filename'],
+ telemetry_info['is_ref'],
+ telemetry_info['cleanup_dir'])
+ elif log_processor_class.__name__ == 'GTestLogParser':
+ tracker_obj = log_processor_class()
+ elif log_processor_class.__name__ == 'GTestJSONParser':
+ tracker_obj = log_processor_class(
+ options.build_properties.get('mastername'))
+ else:
+ webkit_revision = _GetBlinkRevision(options) or 'undefined'
+ revision = _GetMainRevision(options) or 'undefined'
+
+ tracker_obj = log_processor_class(
+ revision=revision,
+ build_properties=options.build_properties,
+ factory_properties=options.factory_properties,
+ webkit_revision=webkit_revision)
+
+ if options.annotate and options.generate_json_file:
+ tracker_obj.ProcessLine(_GetMasterString(_GetMaster()))
+
+ return tracker_obj
+
+
+def _GetSupplementalColumns(build_dir, supplemental_colummns_file_name):
+ """Reads supplemental columns data from a file.
+
+ Args:
+ build_dir: Build dir name.
+ supplemental_columns_file_name: Name of a file which contains the
+ supplemental columns data (in JSON format).
+
+ Returns:
+ A dict of supplemental data to send to the dashboard.
+ """
+ supplemental_columns = {}
+ supplemental_columns_file = os.path.join(build_dir,
+ results_dashboard.CACHE_DIR,
+ supplemental_colummns_file_name)
+ if os.path.exists(supplemental_columns_file):
+ with file(supplemental_columns_file, 'r') as f:
+ supplemental_columns = json.loads(f.read())
+ return supplemental_columns
+
+
+def _ResultsDashboardDict(options):
+ """Generates a dict of info needed by the results dashboard.
+
+ Args:
+ options: Program arguments.
+
+ Returns:
+ dict containing data the dashboard needs.
+ """
+ build_dir = os.path.abspath(options.build_dir)
+ supplemental_columns = _GetSupplementalColumns(
+ build_dir, options.supplemental_columns_file)
+ extra_columns = options.perf_config
+ if extra_columns:
+ supplemental_columns.update(extra_columns)
+ fields = {
+ 'system': _GetPerfID(options),
+ 'test': options.test_type,
+ 'url': options.results_url,
+ 'mastername': options.build_properties.get('mastername'),
+ 'buildername': options.build_properties.get('buildername'),
+ 'buildnumber': options.build_properties.get('buildnumber'),
+ 'build_dir': build_dir,
+ 'supplemental_columns': supplemental_columns,
+ 'revisions': _GetTelemetryRevisions(options),
+ }
+ return fields
+
+
+def _GenerateDashboardJson(log_processor, args):
+ """Generates chartjson to send to the dashboard.
+
+ Args:
+ log_processor: An instance of a log processor class, which has been used to
+ process the test output, so it contains the test results.
+ args: Dict of additional args to send to results_dashboard.
+ """
+ assert log_processor.IsChartJson()
+
+ chart_json = log_processor.ChartJson()
+ if chart_json:
+ return results_dashboard.MakeDashboardJsonV1(
+ chart_json,
+ args['revisions'], args['system'], args['mastername'],
+ args['buildername'], args['buildnumber'],
+ args['supplemental_columns'], log_processor.IsReferenceBuild())
+ return None
+
+
+def _WriteLogProcessorResultsToOutput(log_processor, log_output_file):
+ """Writes the log processor's results to a file.
+
+ Args:
+ chartjson_file: Path to the file to write the results.
+ log_processor: An instance of a log processor class, which has been used to
+ process the test output, so it contains the test results.
+ """
+ with open(log_output_file, 'w') as f:
+ results = {
+ 'passed': log_processor.PassedTests(),
+ 'failed': log_processor.FailedTests(),
+ 'flakes': log_processor.FlakyTests(),
+ }
+ json.dump(results, f)
+
+
+def _WriteChartJsonToOutput(chartjson_file, log_processor, args):
+ """Writes the dashboard chartjson to a file for display in the waterfall.
+
+ Args:
+ chartjson_file: Path to the file to write the chartjson.
+ log_processor: An instance of a log processor class, which has been used to
+ process the test output, so it contains the test results.
+ args: Dict of additional args to send to results_dashboard.
+ """
+ assert log_processor.IsChartJson()
+
+ chartjson_data = _GenerateDashboardJson(log_processor, args)
+
+ with open(chartjson_file, 'w') as f:
+ json.dump(chartjson_data, f)
+
+
+def _SendResultsToDashboard(log_processor, args):
+ """Sends results from a log processor instance to the dashboard.
+
+ Args:
+ log_processor: An instance of a log processor class, which has been used to
+ process the test output, so it contains the test results.
+ args: Dict of additional args to send to results_dashboard.
+
+ Returns:
+ True if no errors occurred.
+ """
+ if args['system'] is None:
+ # perf_id not specified in factory properties.
+ print 'Error: No system name (perf_id) specified when sending to dashboard.'
+ return True
+
+ results = None
+ if log_processor.IsChartJson():
+ results = _GenerateDashboardJson(log_processor, args)
+ if not results:
+ print 'Error: No json output from telemetry.'
+ print '@@@STEP_FAILURE@@@'
+ log_processor.Cleanup()
+ else:
+ charts = _GetDataFromLogProcessor(log_processor)
+ results = results_dashboard.MakeListOfPoints(
+ charts, args['system'], args['test'], args['mastername'],
+ args['buildername'], args['buildnumber'], args['supplemental_columns'])
+
+ if not results:
+ return False
+
+ logging.debug(json.dumps(results, indent=2))
+ return results_dashboard.SendResults(results, args['url'], args['build_dir'])
+
+
+def _GetDataFromLogProcessor(log_processor):
+ """Returns a mapping of chart names to chart data.
+
+ Args:
+ log_processor: A log processor (aka results tracker) object.
+
+ Returns:
+ A dictionary mapping chart name to lists of chart data.
+ put together in log_processor. Each chart data dictionary contains:
+ "traces": A dictionary mapping trace names to value, stddev pairs.
+ "units": Units for the chart.
+ "rev": A revision number or git hash.
+ Plus other revision keys, e.g. webkit_rev, ver, v8_rev.
+ """
+ charts = {}
+ for log_file_name, line_list in log_processor.PerformanceLogs().iteritems():
+ if not log_file_name.endswith('-summary.dat'):
+ # The log processor data also contains "graphs list" file contents,
+ # which we can ignore.
+ continue
+ chart_name = log_file_name.replace('-summary.dat', '')
+
+ # It's assumed that the log lines list has length one, because for each
+ # graph name only one line is added in log_processor in the method
+ # GraphingLogProcessor._CreateSummaryOutput.
+ if len(line_list) != 1:
+ print 'Error: Unexpected log processor line list: %s' % str(line_list)
+ continue
+ line = line_list[0].rstrip()
+ try:
+ charts[chart_name] = json.loads(line)
+ except ValueError:
+ print 'Error: Could not parse JSON: %s' % line
+ return charts
+
+
+def _BuildCoverageGtestExclusions(options, args):
+ """Appends a list of GTest exclusion filters to the args list."""
+ gtest_exclusions = {
+ 'win32': {
+ 'browser_tests': (
+ 'ChromeNotifierDelegateBrowserTest.ClickTest',
+ 'ChromeNotifierDelegateBrowserTest.ButtonClickTest',
+ 'SyncFileSystemApiTest.GetFileStatuses',
+ 'SyncFileSystemApiTest.WriteFileThenGetUsage',
+ 'NaClExtensionTest.HostedApp',
+ 'MediaGalleriesPlatformAppBrowserTest.MediaGalleriesCopyToNoAccess',
+ 'PlatformAppBrowserTest.ComponentAppBackgroundPage',
+ 'BookmarksTest.CommandAgainGoesBackToBookmarksTab',
+ 'NotificationBitmapFetcherBrowserTest.OnURLFetchFailureTest',
+ 'PreservedWindowPlacementIsMigrated.Test',
+ 'ShowAppListBrowserTest.ShowAppListFlag',
+ '*AvatarMenuButtonTest.*',
+ 'NotificationBitmapFetcherBrowserTest.HandleImageFailedTest',
+ 'NotificationBitmapFetcherBrowserTest.OnImageDecodedTest',
+ 'NotificationBitmapFetcherBrowserTest.StartTest',
+ )
+ },
+ 'darwin2': {},
+ 'linux2': {},
+ }
+ gtest_exclusion_filters = []
+ if sys.platform in gtest_exclusions:
+ excldict = gtest_exclusions.get(sys.platform)
+ if options.test_type in excldict:
+ gtest_exclusion_filters = excldict[options.test_type]
+ args.append('--gtest_filter=-' + ':'.join(gtest_exclusion_filters))
+
+
+def _UploadProfilingData(options, args):
+ """Archives profiling data to Google Storage."""
+ # args[1] has --gtest-filter argument.
+ if len(args) < 2:
+ return 0
+
+ builder_name = options.build_properties.get('buildername')
+ if ((builder_name != 'XP Perf (dbg) (2)' and
+ builder_name != 'Linux Perf (lowmem)') or
+ options.build_properties.get('mastername') != 'chromium.perf' or
+ not options.build_properties.get('got_revision')):
+ return 0
+
+ gtest_filter = args[1]
+ if gtest_filter is None:
+ return 0
+ gtest_name = ''
+ if gtest_filter.find('StartupTest.*') > -1:
+ gtest_name = 'StartupTest'
+ else:
+ return 0
+
+ build_dir = os.path.normpath(os.path.abspath(options.build_dir))
+
+ # archive_profiling_data.py is in /b/build/scripts/slave and
+ # build_dir is /b/build/slave/SLAVE_NAME/build/src/build.
+ profiling_archive_tool = os.path.join(build_dir, '..', '..', '..', '..', '..',
+ 'scripts', 'slave',
+ 'archive_profiling_data.py')
+
+ if sys.platform == 'win32':
+ python = 'python_slave'
+ else:
+ python = 'python'
+
+ revision = options.build_properties.get('got_revision')
+ cmd = [python, profiling_archive_tool, '--revision', revision,
+ '--builder-name', builder_name, '--test-name', gtest_name]
+
+ return chromium_utils.RunCommand(cmd)
+
+
+def _UploadGtestJsonSummary(json_path, build_properties, test_exe, step_name):
+ """Archives GTest results to Google Storage.
+
+ Args:
+ json_path: path to the json-format output of the gtest.
+ build_properties: the build properties of a build in buildbot.
+ test_exe: the name of the gtest executable.
+ step_name: the name of the buildbot step running the gtest.
+ """
+ if not os.path.exists(json_path):
+ return
+
+ orig_json_data = 'invalid'
+ try:
+ with open(json_path) as orig_json:
+ orig_json_data = json.load(orig_json)
+ except ValueError:
+ pass
+
+ target_json = {
+ # Increment the version number when making incompatible changes
+ # to the layout of this dict. This way clients can recognize different
+ # formats instead of guessing.
+ 'version': 1,
+ 'timestamp': str(datetime.datetime.now()),
+ 'test_exe': test_exe,
+ 'build_properties': build_properties,
+ 'gtest_results': orig_json_data,
+ }
+ target_json_serialized = json.dumps(target_json, indent=2)
+
+ now = datetime.datetime.utcnow()
+ today = now.date()
+ weekly_timestamp = today - datetime.timedelta(days=today.weekday())
+
+ # Pick a non-colliding file name by hashing the JSON contents
+ # (build metadata should be different from build to build).
+ target_name = hashlib.sha1(target_json_serialized).hexdigest()
+
+ # Use a directory structure that makes it easy to filter by year,
+ # month, week and day based just on the file path.
+ date_json_gs_path = 'gs://chrome-gtest-results/raw/%d/%d/%d/%d/%s.json.gz' % (
+ weekly_timestamp.year,
+ weekly_timestamp.month,
+ weekly_timestamp.day,
+ today.day,
+ target_name)
+
+ # Use a directory structure so that the json results could be indexed by
+ # master_name/builder_name/build_number/step_name.
+ master_name = build_properties.get('mastername')
+ builder_name = build_properties.get('buildername')
+ build_number = build_properties.get('buildnumber')
+ buildbot_json_gs_path = ''
+ if (master_name and builder_name and
+ (build_number is not None and build_number != '') and step_name):
+ # build_number could be zero.
+ buildbot_json_gs_path = (
+ 'gs://chrome-gtest-results/buildbot/%s/%s/%d/%s.json.gz' % (
+ master_name,
+ builder_name,
+ build_number,
+ step_name))
+
+ fd, target_json_path = tempfile.mkstemp()
+ try:
+ with os.fdopen(fd, 'w') as f:
+ with gzip.GzipFile(fileobj=f, compresslevel=9) as gzipf:
+ gzipf.write(target_json_serialized)
+
+ slave_utils.GSUtilCopy(target_json_path, date_json_gs_path)
+ if buildbot_json_gs_path:
+ slave_utils.GSUtilCopy(target_json_path, buildbot_json_gs_path)
+ finally:
+ os.remove(target_json_path)
+
+ if target_json['gtest_results'] == 'invalid':
+ return
+
+ # Use a directory structure that makes it easy to filter by year,
+ # month, week and day based just on the file path.
+ bigquery_json_gs_path = (
+ 'gs://chrome-gtest-results/bigquery/%d/%d/%d/%d/%s.json.gz' % (
+ weekly_timestamp.year,
+ weekly_timestamp.month,
+ weekly_timestamp.day,
+ today.day,
+ target_name))
+
+ fd, bigquery_json_path = tempfile.mkstemp()
+ try:
+ with os.fdopen(fd, 'w') as f:
+ with gzip.GzipFile(fileobj=f, compresslevel=9) as gzipf:
+ for iteration_data in (
+ target_json['gtest_results']['per_iteration_data']):
+ for test_name, test_runs in iteration_data.iteritems():
+ # Compute the number of flaky failures. A failure is only considered
+ # flaky, when the test succeeds at least once on the same code.
+ # However, we do not consider a test flaky if it only changes
+ # between various failure states, e.g. FAIL and TIMEOUT.
+ num_successes = len([r['status'] for r in test_runs
+ if r['status'] == 'SUCCESS'])
+ num_failures = len(test_runs) - num_successes
+ if num_failures > 0 and num_successes > 0:
+ flaky_failures = num_failures
+ else:
+ flaky_failures = 0
+
+ for run_index, run_data in enumerate(test_runs):
+ row = {
+ 'test_name': test_name,
+ 'run_index': run_index,
+ 'elapsed_time_ms': run_data['elapsed_time_ms'],
+ 'status': run_data['status'],
+ 'test_exe': target_json['test_exe'],
+ 'global_tags': target_json['gtest_results']['global_tags'],
+ 'slavename':
+ target_json['build_properties'].get('slavename', ''),
+ 'buildername':
+ target_json['build_properties'].get('buildername', ''),
+ 'mastername':
+ target_json['build_properties'].get('mastername', ''),
+ 'raw_json_gs_path': date_json_gs_path,
+ 'timestamp': now.strftime('%Y-%m-%d %H:%M:%S.%f'),
+ 'flaky_failures': flaky_failures,
+ 'num_successes': num_successes,
+ 'num_failures': num_failures
+ }
+ gzipf.write(json.dumps(row) + '\n')
+
+ slave_utils.GSUtilCopy(bigquery_json_path, bigquery_json_gs_path)
+ finally:
+ os.remove(bigquery_json_path)
+
+
+def _GenerateRunIsolatedCommand(build_dir, test_exe_path, options, command):
+ """Converts the command to run through the run isolate script.
+
+ All commands are sent through the run isolated script, in case
+ they need to be run in isolate mode.
+ """
+ run_isolated_test = os.path.join(BASE_DIR, 'runisolatedtest.py')
+ isolate_command = [
+ sys.executable, run_isolated_test,
+ '--test_name', options.test_type,
+ '--builder_name', options.build_properties.get('buildername', ''),
+ '--checkout_dir', os.path.dirname(os.path.dirname(build_dir)),
+ ]
+ if options.factory_properties.get('force_isolated'):
+ isolate_command += ['--force-isolated']
+ isolate_command += [test_exe_path, '--'] + command
+
+ return isolate_command
+
+
+def _GetPerfID(options):
+ if options.perf_id:
+ perf_id = options.perf_id
+ else:
+ perf_id = options.factory_properties.get('perf_id')
+ if options.factory_properties.get('add_perf_id_suffix'):
+ perf_id += options.build_properties.get('perf_id_suffix')
+ return perf_id
+
+
+def _GetSanitizerSymbolizeCommand(strip_path_prefix=None, json_file_name=None):
+ script_path = os.path.abspath(os.path.join('src', 'tools', 'valgrind',
+ 'asan', 'asan_symbolize.py'))
+ command = [sys.executable, script_path]
+ if strip_path_prefix:
+ command.append(strip_path_prefix)
+ if json_file_name:
+ command.append('--test-summary-json-file=%s' % json_file_name)
+ return command
+
+
+def _SymbolizeSnippetsInJSON(options, json_file_name):
+ if not json_file_name:
+ return
+ symbolize_command = _GetSanitizerSymbolizeCommand(
+ strip_path_prefix=options.strip_path_prefix,
+ json_file_name=json_file_name)
+ try:
+ p = subprocess.Popen(symbolize_command, stderr=subprocess.PIPE)
+ (_, stderr) = p.communicate()
+ except OSError as e:
+ print 'Exception while symbolizing snippets: %s' % e
+
+ if p.returncode != 0:
+ print "Error: failed to symbolize snippets in JSON:\n"
+ print stderr
+
+
+def _MainParse(options, _args):
+ """Run input through annotated test parser.
+
+ This doesn't execute a test, but reads test input from a file and runs it
+ through the specified annotation parser (aka log processor).
+ """
+ if not options.annotate:
+ raise chromium_utils.MissingArgument('--parse-input doesn\'t make sense '
+ 'without --annotate.')
+
+ # If --annotate=list was passed, list the log processor classes and exit.
+ if _ListLogProcessors(options.annotate):
+ return 0
+
+ log_processor_class = _SelectLogProcessor(options, False)
+ log_processor = _CreateLogProcessor(log_processor_class, options, None)
+
+ if options.generate_json_file:
+ if os.path.exists(options.test_output_xml):
+ # remove the old XML output file.
+ os.remove(options.test_output_xml)
+
+ if options.parse_input == '-':
+ f = sys.stdin
+ else:
+ try:
+ f = open(options.parse_input, 'rb')
+ except IOError as e:
+ print 'Error %d opening \'%s\': %s' % (e.errno, options.parse_input,
+ e.strerror)
+ return 1
+
+ with f:
+ for line in f:
+ log_processor.ProcessLine(line)
+
+ if options.generate_json_file:
+ if not _GenerateJSONForTestResults(options, log_processor):
+ return 1
+
+ if options.annotate:
+ annotation_utils.annotate(
+ options.test_type, options.parse_result, log_processor,
+ perf_dashboard_id=options.perf_dashboard_id)
+
+ return options.parse_result
+
+
+def _MainMac(options, args, extra_env):
+ """Runs the test on mac."""
+ if len(args) < 1:
+ raise chromium_utils.MissingArgument('Usage: %s' % USAGE)
+
+ telemetry_info = _UpdateRunBenchmarkArgs(args, options)
+ test_exe = args[0]
+ if options.run_python_script:
+ build_dir = os.path.normpath(os.path.abspath(options.build_dir))
+ test_exe_path = test_exe
+ else:
+ build_dir = os.path.normpath(os.path.abspath(options.build_dir))
+ test_exe_path = os.path.join(build_dir, options.target, test_exe)
+
+ # Nuke anything that appears to be stale chrome items in the temporary
+ # directory from previous test runs (i.e.- from crashes or unittest leaks).
+ slave_utils.RemoveChromeTemporaryFiles()
+
+ if options.run_shell_script:
+ command = ['bash', test_exe_path]
+ elif options.run_python_script:
+ command = [sys.executable, test_exe]
+ else:
+ command = _BuildTestBinaryCommand(build_dir, test_exe_path, options)
+ command.extend(args[1:])
+
+ # If --annotate=list was passed, list the log processor classes and exit.
+ if _ListLogProcessors(options.annotate):
+ return 0
+ log_processor_class = _SelectLogProcessor(options, bool(telemetry_info))
+ log_processor = _CreateLogProcessor(
+ log_processor_class, options, telemetry_info)
+
+ if options.generate_json_file:
+ if os.path.exists(options.test_output_xml):
+ # remove the old XML output file.
+ os.remove(options.test_output_xml)
+
+ try:
+ if _UsingGtestJson(options):
+ json_file_name = log_processor.PrepareJSONFile(
+ options.test_launcher_summary_output)
+ command.append('--test-launcher-summary-output=%s' % json_file_name)
+
+ pipes = []
+ if options.use_symbolization_script:
+ pipes = [_GetSanitizerSymbolizeCommand()]
+
+ command = _GenerateRunIsolatedCommand(build_dir, test_exe_path, options,
+ command)
+ result = _RunGTestCommand(options, command, extra_env, pipes=pipes,
+ log_processor=log_processor)
+ finally:
+ if _UsingGtestJson(options):
+ _UploadGtestJsonSummary(json_file_name,
+ options.build_properties,
+ test_exe,
+ options.step_name)
+ log_processor.ProcessJSONFile(options.build_dir)
+
+ if options.generate_json_file:
+ if not _GenerateJSONForTestResults(options, log_processor):
+ return 1
+
+ if options.annotate:
+ annotation_utils.annotate(
+ options.test_type, result, log_processor,
+ perf_dashboard_id=options.perf_dashboard_id)
+
+ if options.chartjson_file and telemetry_info:
+ _WriteChartJsonToOutput(options.chartjson_file,
+ log_processor,
+ _ResultsDashboardDict(options))
+
+ if options.results_url:
+ if not _SendResultsToDashboard(
+ log_processor, _ResultsDashboardDict(options)):
+ return 1
+
+ return result
+
+
+def _MainIOS(options, args, extra_env):
+ """Runs the test on iOS."""
+ if len(args) < 1:
+ raise chromium_utils.MissingArgument('Usage: %s' % USAGE)
+
+ def kill_simulator():
+ chromium_utils.RunCommand(['/usr/bin/killall', 'iPhone Simulator'])
+
+ # For iOS tests, the args come in in the following order:
+ # [0] test display name formatted as 'test_name (device[ ios_version])'
+ # [1:] gtest args (e.g. --gtest_print_time)
+
+ # Set defaults in case the device family and iOS version can't be parsed out
+ # of |args|
+ device = 'iPhone Retina (4-inch)'
+ ios_version = '7.1'
+
+ # Parse the test_name and device from the test display name.
+ # The expected format is: <test_name> (<device>)
+ result = re.match(r'(.*) \((.*)\)$', args[0])
+ if result is not None:
+ test_name, device = result.groups()
+ # Check if the device has an iOS version. The expected format is:
+ # <device_name><space><ios_version>, where ios_version may have 2 or 3
+ # numerals (e.g. '4.3.11' or '5.0').
+ result = re.match(r'(.*) (\d+\.\d+(\.\d+)?)$', device)
+ if result is not None:
+ device = result.groups()[0]
+ ios_version = result.groups()[1]
+ else:
+ # If first argument is not in the correct format, log a warning but
+ # fall back to assuming the first arg is the test_name and just run
+ # on the iphone simulator.
+ test_name = args[0]
+ print ('Can\'t parse test name, device, and iOS version. '
+ 'Running %s on %s %s' % (test_name, device, ios_version))
+
+ # Build the args for invoking iossim, which will install the app on the
+ # simulator and launch it, then dump the test results to stdout.
+
+ build_dir = os.path.normpath(os.path.abspath(options.build_dir))
+ app_exe_path = os.path.join(
+ build_dir, options.target + '-iphonesimulator', test_name + '.app')
+ test_exe_path = os.path.join(
+ build_dir, 'ninja-iossim', options.target, 'iossim')
+ tmpdir = tempfile.mkdtemp()
+ command = [test_exe_path,
+ '-d', device,
+ '-s', ios_version,
+ '-t', '120',
+ '-u', tmpdir,
+ app_exe_path, '--'
+ ]
+ command.extend(args[1:])
+
+ # If --annotate=list was passed, list the log processor classes and exit.
+ if _ListLogProcessors(options.annotate):
+ return 0
+ log_processor = _CreateLogProcessor(
+ LOG_PROCESSOR_CLASSES['gtest'], options, None)
+
+ # Make sure the simulator isn't running.
+ kill_simulator()
+
+ # Nuke anything that appears to be stale chrome items in the temporary
+ # directory from previous test runs (i.e.- from crashes or unittest leaks).
+ slave_utils.RemoveChromeTemporaryFiles()
+
+ dirs_to_cleanup = [tmpdir]
+ crash_files_before = set([])
+ crash_files_after = set([])
+ crash_files_before = set(crash_utils.list_crash_logs())
+
+ result = _RunGTestCommand(options, command, extra_env, log_processor)
+
+ # Because test apps kill themselves, iossim sometimes returns non-zero
+ # status even though all tests have passed. Check the log_processor to
+ # see if the test run was successful.
+ if log_processor.CompletedWithoutFailure():
+ result = 0
+ else:
+ result = 1
+
+ if result != 0:
+ crash_utils.wait_for_crash_logs()
+ crash_files_after = set(crash_utils.list_crash_logs())
+
+ kill_simulator()
+
+ new_crash_files = crash_files_after.difference(crash_files_before)
+ crash_utils.print_new_crash_files(new_crash_files)
+
+ for a_dir in dirs_to_cleanup:
+ try:
+ chromium_utils.RemoveDirectory(a_dir)
+ except OSError as e:
+ print >> sys.stderr, e
+ # Don't fail.
+
+ return result
+
+
+def _MainLinux(options, args, extra_env):
+ """Runs the test on Linux."""
+ import platform
+ xvfb_path = os.path.join(os.path.dirname(sys.argv[0]), '..', '..',
+ 'third_party', 'xvfb', platform.architecture()[0])
+
+ if len(args) < 1:
+ raise chromium_utils.MissingArgument('Usage: %s' % USAGE)
+
+ build_dir = os.path.normpath(os.path.abspath(options.build_dir))
+ if options.slave_name:
+ slave_name = options.slave_name
+ else:
+ slave_name = slave_utils.SlaveBuildName(build_dir)
+ bin_dir = os.path.join(build_dir, options.target)
+
+ # Figure out what we want for a special frame buffer directory.
+ special_xvfb_dir = None
+ fp_chromeos = options.factory_properties.get('chromeos', None)
+ if (fp_chromeos or
+ slave_utils.GypFlagIsOn(options, 'use_aura') or
+ slave_utils.GypFlagIsOn(options, 'chromeos')):
+ special_xvfb_dir = xvfb_path
+
+ telemetry_info = _UpdateRunBenchmarkArgs(args, options)
+ test_exe = args[0]
+ if options.run_python_script:
+ test_exe_path = test_exe
+ else:
+ test_exe_path = os.path.join(bin_dir, test_exe)
+ if not os.path.exists(test_exe_path):
+ if options.factory_properties.get('succeed_on_missing_exe', False):
+ print '%s missing but succeed_on_missing_exe used, exiting' % (
+ test_exe_path)
+ return 0
+ msg = 'Unable to find %s' % test_exe_path
+ raise chromium_utils.PathNotFound(msg)
+
+ # Unset http_proxy and HTTPS_PROXY environment variables. When set, this
+ # causes some tests to hang. See http://crbug.com/139638 for more info.
+ if 'http_proxy' in os.environ:
+ del os.environ['http_proxy']
+ print 'Deleted http_proxy environment variable.'
+ if 'HTTPS_PROXY' in os.environ:
+ del os.environ['HTTPS_PROXY']
+ print 'Deleted HTTPS_PROXY environment variable.'
+
+ # Path to SUID sandbox binary. This must be installed on all bots.
+ extra_env['CHROME_DEVEL_SANDBOX'] = CHROME_SANDBOX_PATH
+
+ # Nuke anything that appears to be stale chrome items in the temporary
+ # directory from previous test runs (i.e.- from crashes or unittest leaks).
+ slave_utils.RemoveChromeTemporaryFiles()
+
+ extra_env['LD_LIBRARY_PATH'] = ''
+
+ if options.enable_lsan:
+ # Use the debug version of libstdc++ under LSan. If we don't, there will be
+ # a lot of incomplete stack traces in the reports.
+ extra_env['LD_LIBRARY_PATH'] += '/usr/lib/x86_64-linux-gnu/debug:'
+
+ extra_env['LD_LIBRARY_PATH'] += '%s:%s/lib:%s/lib.target' % (bin_dir, bin_dir,
+ bin_dir)
+
+ if options.run_shell_script:
+ command = ['bash', test_exe_path]
+ elif options.run_python_script:
+ command = [sys.executable, test_exe]
+ else:
+ command = _BuildTestBinaryCommand(build_dir, test_exe_path, options)
+ command.extend(args[1:])
+
+ # If --annotate=list was passed, list the log processor classes and exit.
+ if _ListLogProcessors(options.annotate):
+ return 0
+ log_processor_class = _SelectLogProcessor(options, bool(telemetry_info))
+ log_processor = _CreateLogProcessor(
+ log_processor_class, options, telemetry_info)
+
+ if options.generate_json_file:
+ if os.path.exists(options.test_output_xml):
+ # remove the old XML output file.
+ os.remove(options.test_output_xml)
+
+ try:
+ start_xvfb = False
+ json_file_name = None
+
+ # TODO(dpranke): checking on test_exe is a temporary hack until we
+ # can change the buildbot master to pass --xvfb instead of --no-xvfb
+ # for these two steps. See
+ # https://code.google.com/p/chromium/issues/detail?id=179814
+ start_xvfb = (options.xvfb or
+ 'layout_test_wrapper' in test_exe or
+ 'devtools_perf_test_wrapper' in test_exe)
+ if start_xvfb:
+ xvfb.StartVirtualX(
+ slave_name, bin_dir,
+ with_wm=(options.factory_properties.get('window_manager', 'True') ==
+ 'True'),
+ server_dir=special_xvfb_dir)
+
+ if _UsingGtestJson(options):
+ json_file_name = log_processor.PrepareJSONFile(
+ options.test_launcher_summary_output)
+ command.append('--test-launcher-summary-output=%s' % json_file_name)
+
+ pipes = []
+ # See the comment in main() regarding offline symbolization.
+ if options.use_symbolization_script:
+ symbolize_command = _GetSanitizerSymbolizeCommand(
+ strip_path_prefix=options.strip_path_prefix)
+ pipes = [symbolize_command]
+
+ command = _GenerateRunIsolatedCommand(build_dir, test_exe_path, options,
+ command)
+ result = _RunGTestCommand(options, command, extra_env, pipes=pipes,
+ log_processor=log_processor)
+ finally:
+ if start_xvfb:
+ xvfb.StopVirtualX(slave_name)
+ if _UsingGtestJson(options):
+ if options.use_symbolization_script:
+ _SymbolizeSnippetsInJSON(options, json_file_name)
+ if json_file_name:
+ _UploadGtestJsonSummary(json_file_name,
+ options.build_properties,
+ test_exe,
+ options.step_name)
+ log_processor.ProcessJSONFile(options.build_dir)
+
+ if options.generate_json_file:
+ if not _GenerateJSONForTestResults(options, log_processor):
+ return 1
+
+ if options.annotate:
+ annotation_utils.annotate(
+ options.test_type, result, log_processor,
+ perf_dashboard_id=options.perf_dashboard_id)
+
+ if options.chartjson_file and telemetry_info:
+ _WriteChartJsonToOutput(options.chartjson_file,
+ log_processor,
+ _ResultsDashboardDict(options))
+
+ if options.results_url:
+ if not _SendResultsToDashboard(
+ log_processor, _ResultsDashboardDict(options)):
+ return 1
+
+ return result
+
+
+def _MainWin(options, args, extra_env):
+ """Runs tests on windows.
+
+ Using the target build configuration, run the executable given in the
+ first non-option argument, passing any following arguments to that
+ executable.
+
+ Args:
+ options: Command-line options for this invocation of runtest.py.
+ args: Command and arguments for the test.
+ extra_env: A dictionary of extra environment variables to set.
+
+ Returns:
+ Exit status code.
+ """
+ if len(args) < 1:
+ raise chromium_utils.MissingArgument('Usage: %s' % USAGE)
+
+ telemetry_info = _UpdateRunBenchmarkArgs(args, options)
+ test_exe = args[0]
+ build_dir = os.path.abspath(options.build_dir)
+ if options.run_python_script:
+ test_exe_path = test_exe
+ else:
+ test_exe_path = os.path.join(build_dir, options.target, test_exe)
+
+ if not os.path.exists(test_exe_path):
+ if options.factory_properties.get('succeed_on_missing_exe', False):
+ print '%s missing but succeed_on_missing_exe used, exiting' % (
+ test_exe_path)
+ return 0
+ raise chromium_utils.PathNotFound('Unable to find %s' % test_exe_path)
+
+ if options.run_python_script:
+ command = [sys.executable, test_exe]
+ else:
+ command = _BuildTestBinaryCommand(build_dir, test_exe_path, options)
+
+ command.extend(args[1:])
+
+ # Nuke anything that appears to be stale chrome items in the temporary
+ # directory from previous test runs (i.e.- from crashes or unittest leaks).
+ slave_utils.RemoveChromeTemporaryFiles()
+
+ # If --annotate=list was passed, list the log processor classes and exit.
+ if _ListLogProcessors(options.annotate):
+ return 0
+ log_processor_class = _SelectLogProcessor(options, bool(telemetry_info))
+ log_processor = _CreateLogProcessor(
+ log_processor_class, options, telemetry_info)
+
+ if options.generate_json_file:
+ if os.path.exists(options.test_output_xml):
+ # remove the old XML output file.
+ os.remove(options.test_output_xml)
+
+ try:
+ if _UsingGtestJson(options):
+ json_file_name = log_processor.PrepareJSONFile(
+ options.test_launcher_summary_output)
+ command.append('--test-launcher-summary-output=%s' % json_file_name)
+
+ command = _GenerateRunIsolatedCommand(build_dir, test_exe_path, options,
+ command)
+ result = _RunGTestCommand(options, command, extra_env, log_processor)
+ finally:
+ if _UsingGtestJson(options):
+ _UploadGtestJsonSummary(json_file_name,
+ options.build_properties,
+ test_exe,
+ options.step_name)
+ log_processor.ProcessJSONFile(options.build_dir)
+
+ if options.generate_json_file:
+ if not _GenerateJSONForTestResults(options, log_processor):
+ return 1
+
+ if options.annotate:
+ annotation_utils.annotate(
+ options.test_type, result, log_processor,
+ perf_dashboard_id=options.perf_dashboard_id)
+
+ if options.chartjson_file and telemetry_info:
+ _WriteChartJsonToOutput(options.chartjson_file,
+ log_processor,
+ _ResultsDashboardDict(options))
+
+ if options.results_url:
+ if not _SendResultsToDashboard(
+ log_processor, _ResultsDashboardDict(options)):
+ return 1
+
+ return result
+
+
+def _MainAndroid(options, args, extra_env):
+ """Runs tests on android.
+
+ Running GTest-based tests on android is different than on Linux as it requires
+ src/build/android/test_runner.py to deploy and communicate with the device.
+ Python scripts are the same as with Linux.
+
+ Args:
+ options: Command-line options for this invocation of runtest.py.
+ args: Command and arguments for the test.
+ extra_env: A dictionary of extra environment variables to set.
+
+ Returns:
+ Exit status code.
+ """
+ if options.run_python_script:
+ return _MainLinux(options, args, extra_env)
+
+ if len(args) < 1:
+ raise chromium_utils.MissingArgument('Usage: %s' % USAGE)
+
+ if _ListLogProcessors(options.annotate):
+ return 0
+ log_processor_class = _SelectLogProcessor(options, False)
+ log_processor = _CreateLogProcessor(log_processor_class, options, None)
+
+ if options.generate_json_file:
+ if os.path.exists(options.test_output_xml):
+ # remove the old XML output file.
+ os.remove(options.test_output_xml)
+
+ # Assume it's a gtest apk, so use the android harness.
+ test_suite = args[0]
+ run_test_target_option = '--release'
+ if options.target == 'Debug':
+ run_test_target_option = '--debug'
+ command = ['src/build/android/test_runner.py', 'gtest',
+ run_test_target_option, '-s', test_suite]
+
+ if options.flakiness_dashboard_server:
+ command += ['--flakiness-dashboard-server=%s' %
+ options.flakiness_dashboard_server]
+
+ result = _RunGTestCommand(
+ options, command, extra_env, log_processor=log_processor)
+
+ if options.generate_json_file:
+ if not _GenerateJSONForTestResults(options, log_processor):
+ return 1
+
+ if options.annotate:
+ annotation_utils.annotate(
+ options.test_type, result, log_processor,
+ perf_dashboard_id=options.perf_dashboard_id)
+
+ if options.results_url:
+ if not _SendResultsToDashboard(
+ log_processor, _ResultsDashboardDict(options)):
+ return 1
+
+ return result
+
+
+def _UpdateRunBenchmarkArgs(args, options):
+ """Updates the arguments for telemetry run_benchmark commands.
+
+ Ensures that --output=chartjson is set and adds a --output argument.
+
+ Arguments:
+ args: list of command line arguments, starts with 'run_benchmark' for
+ telemetry tests.
+
+ Returns:
+ None if not a telemetry test, otherwise a
+ dict containing the output filename and whether it is a reference build.
+ """
+ if not options.chartjson_file:
+ return {}
+
+ if args[0].endswith('run_benchmark'):
+ is_ref = '--browser=reference' in args
+ output_dir = tempfile.mkdtemp()
+ args.extend(['--output-dir=%s' % output_dir])
+ temp_filename = os.path.join(output_dir, 'results-chart.json')
+ return {'filename': temp_filename, 'is_ref': is_ref, 'cleanup_dir': True}
+ elif args[0].endswith('test_runner.py'):
+ (_, temp_json_filename) = tempfile.mkstemp()
+ args.extend(['--output-chartjson-data=%s' % temp_json_filename])
+ return {'filename': temp_json_filename,
+ 'is_ref': False,
+ 'cleanup_dir': False}
+
+ return None
+
+
+def _ConfigureSanitizerTools(options, args, extra_env):
+ if (options.enable_asan or options.enable_tsan or
+ options.enable_msan or options.enable_lsan):
+ # Instruct GTK to use malloc while running ASan, TSan, MSan or LSan tests.
+ extra_env['G_SLICE'] = 'always-malloc'
+ extra_env['NSS_DISABLE_ARENA_FREE_LIST'] = '1'
+ extra_env['NSS_DISABLE_UNLOAD'] = '1'
+
+ symbolizer_path = os.path.abspath(os.path.join('src', 'third_party',
+ 'llvm-build', 'Release+Asserts', 'bin', 'llvm-symbolizer'))
+ disable_sandbox_flag = '--no-sandbox'
+ if args and 'layout_test_wrapper' in args[0]:
+ disable_sandbox_flag = '--additional-drt-flag=%s' % disable_sandbox_flag
+
+ # Symbolization of sanitizer reports.
+ if sys.platform in ['win32', 'cygwin']:
+ # On Windows, the in-process symbolizer works even when sandboxed.
+ symbolization_options = []
+ elif options.enable_tsan or options.enable_lsan:
+ # TSan and LSan are not sandbox-compatible, so we can use online
+ # symbolization. In fact, they need symbolization to be able to apply
+ # suppressions.
+ symbolization_options = ['symbolize=1',
+ 'external_symbolizer_path=%s' % symbolizer_path,
+ 'strip_path_prefix=%s' % options.strip_path_prefix]
+ elif options.enable_asan or options.enable_msan:
+ # ASan and MSan use a script for offline symbolization.
+ # Important note: when running ASan or MSan with leak detection enabled,
+ # we must use the LSan symbolization options above.
+ symbolization_options = ['symbolize=0']
+ # Set the path to llvm-symbolizer to be used by asan_symbolize.py
+ extra_env['LLVM_SYMBOLIZER_PATH'] = symbolizer_path
+ options.use_symbolization_script = True
+
+ def AddToExistingEnv(env_dict, key, options_list):
+ # Adds a key to the supplied environment dictionary but appends it to
+ # existing environment variables if it already contains values.
+ assert type(env_dict) is dict
+ assert type(options_list) is list
+ env_dict[key] = ' '.join(filter(bool, [os.environ.get(key)]+options_list))
+
+ # ThreadSanitizer
+ if options.enable_tsan:
+ tsan_options = symbolization_options
+ AddToExistingEnv(extra_env, 'TSAN_OPTIONS', tsan_options)
+ # Disable sandboxing under TSan for now. http://crbug.com/223602.
+ args.append(disable_sandbox_flag)
+
+ # LeakSanitizer
+ if options.enable_lsan:
+ # Symbolization options set here take effect only for standalone LSan.
+ lsan_options = symbolization_options
+ AddToExistingEnv(extra_env, 'LSAN_OPTIONS', lsan_options)
+
+ # Disable sandboxing under LSan.
+ args.append(disable_sandbox_flag)
+
+ # AddressSanitizer
+ if options.enable_asan:
+ asan_options = symbolization_options
+ if options.enable_lsan:
+ asan_options += ['detect_leaks=1']
+ AddToExistingEnv(extra_env, 'ASAN_OPTIONS', asan_options)
+
+ # MemorySanitizer
+ if options.enable_msan:
+ msan_options = symbolization_options
+ if options.enable_lsan:
+ msan_options += ['detect_leaks=1']
+ AddToExistingEnv(extra_env, 'MSAN_OPTIONS', msan_options)
+
+
+def main():
+ """Entry point for runtest.py.
+
+ This function:
+ (1) Sets up the command-line options.
+ (2) Sets environment variables based on those options.
+ (3) Delegates to the platform-specific main functions.
+
+ Returns:
+ Exit code for this script.
+ """
+ option_parser = optparse.OptionParser(usage=USAGE)
+
+ # Since the trailing program to run may have has command-line args of its
+ # own, we need to stop parsing when we reach the first positional argument.
+ option_parser.disable_interspersed_args()
+
+ option_parser.add_option('--target', default='Release',
+ help='build target (Debug or Release)')
+ option_parser.add_option('--pass-target', action='store_true', default=False,
+ help='pass --target to the spawned test script')
+ option_parser.add_option('--build-dir', help='ignored')
+ option_parser.add_option('--pass-build-dir', action='store_true',
+ default=False,
+ help='pass --build-dir to the spawned test script')
+ option_parser.add_option('--test-platform',
+ help='Platform to test on, e.g. ios-simulator')
+ option_parser.add_option('--total-shards', dest='total_shards',
+ default=None, type='int',
+ help='Number of shards to split this test into.')
+ option_parser.add_option('--shard-index', dest='shard_index',
+ default=None, type='int',
+ help='Shard to run. Must be between 1 and '
+ 'total-shards.')
+ option_parser.add_option('--run-shell-script', action='store_true',
+ default=False,
+ help='treat first argument as the shell script'
+ 'to run.')
+ option_parser.add_option('--run-python-script', action='store_true',
+ default=False,
+ help='treat first argument as a python script'
+ 'to run.')
+ option_parser.add_option('--generate-json-file', action='store_true',
+ default=False,
+ help='output JSON results file if specified.')
+ option_parser.add_option('--xvfb', action='store_true', dest='xvfb',
+ default=True,
+ help='Start virtual X server on Linux.')
+ option_parser.add_option('--no-xvfb', action='store_false', dest='xvfb',
+ help='Do not start virtual X server on Linux.')
+ option_parser.add_option('-o', '--results-directory', default='',
+ help='output results directory for JSON file.')
+ option_parser.add_option('--chartjson-file', default='',
+ help='File to dump chartjson results.')
+ option_parser.add_option('--log-processor-output-file', default='',
+ help='File to dump gtest log processor results.')
+ option_parser.add_option('--builder-name', default=None,
+ help='The name of the builder running this script.')
+ option_parser.add_option('--slave-name', default=None,
+ help='The name of the slave running this script.')
+ option_parser.add_option('--master-class-name', default=None,
+ help='The class name of the buildbot master running '
+ 'this script: examples include "Chromium", '
+ '"ChromiumWebkit", and "ChromiumGPU". The '
+ 'flakiness dashboard uses this value to '
+ 'categorize results. See buildershandler.py '
+ 'in the flakiness dashboard code '
+ '(use codesearch) for the known values. '
+ 'Defaults to fetching it from '
+ 'slaves.cfg/builders.pyl.')
+ option_parser.add_option('--build-number', default=None,
+ help=('The build number of the builder running'
+ 'this script.'))
+ option_parser.add_option('--step-name', default=None,
+ help=('The name of the step running this script.'))
+ option_parser.add_option('--test-type', default='',
+ help='The test name that identifies the test, '
+ 'e.g. \'unit-tests\'')
+ option_parser.add_option('--test-results-server', default='',
+ help='The test results server to upload the '
+ 'results.')
+ option_parser.add_option('--annotate', default='',
+ help='Annotate output when run as a buildstep. '
+ 'Specify which type of test to parse, available'
+ ' types listed with --annotate=list.')
+ option_parser.add_option('--parse-input', default='',
+ help='When combined with --annotate, reads test '
+ 'from a file instead of executing a test '
+ 'binary. Use - for stdin.')
+ option_parser.add_option('--parse-result', default=0,
+ help='Sets the return value of the simulated '
+ 'executable under test. Only has meaning when '
+ '--parse-input is used.')
+ option_parser.add_option('--results-url', default='',
+ help='The URI of the perf dashboard to upload '
+ 'results to.')
+ option_parser.add_option('--perf-dashboard-id', default='',
+ help='The ID on the perf dashboard to add results '
+ 'to.')
+ option_parser.add_option('--perf-id', default='',
+ help='The perf builder id')
+ option_parser.add_option('--perf-config', default='',
+ help='Perf configuration dictionary (as a string). '
+ 'This allows to specify custom revisions to be '
+ 'the main revision at the Perf dashboard. '
+ 'Example: --perf-config="{\'a_default_rev\': '
+ '\'r_webrtc_rev\'}"')
+ option_parser.add_option('--supplemental-columns-file',
+ default='supplemental_columns',
+ help='A file containing a JSON blob with a dict '
+ 'that will be uploaded to the results '
+ 'dashboard as supplemental columns.')
+ option_parser.add_option('--revision',
+ help='The revision number which will be is used as '
+ 'primary key by the dashboard. If omitted it '
+ 'is automatically extracted from the checkout.')
+ option_parser.add_option('--webkit-revision',
+ help='See --revision.')
+ option_parser.add_option('--enable-asan', action='store_true', default=False,
+ help='Enable fast memory error detection '
+ '(AddressSanitizer).')
+ option_parser.add_option('--enable-lsan', action='store_true', default=False,
+ help='Enable memory leak detection (LeakSanitizer).')
+ option_parser.add_option('--enable-msan', action='store_true', default=False,
+ help='Enable uninitialized memory reads detection '
+ '(MemorySanitizer).')
+ option_parser.add_option('--enable-tsan', action='store_true', default=False,
+ help='Enable data race detection '
+ '(ThreadSanitizer).')
+ option_parser.add_option('--strip-path-prefix',
+ default='build/src/out/Release/../../',
+ help='Source paths in stack traces will be stripped '
+ 'of prefixes ending with this substring. This '
+ 'option is used by sanitizer tools.')
+ option_parser.add_option('--no-spawn-dbus', action='store_true',
+ default=False,
+ help='Disable GLib DBus bug workaround: '
+ 'manually spawning dbus-launch')
+ option_parser.add_option('--test-launcher-summary-output',
+ help='Path to test results file with all the info '
+ 'from the test launcher')
+ option_parser.add_option('--flakiness-dashboard-server',
+ help='The flakiness dashboard server to which the '
+ 'results should be uploaded.')
+ option_parser.add_option('--verbose', action='store_true', default=False,
+ help='Prints more information.')
+
+ chromium_utils.AddPropertiesOptions(option_parser)
+ options, args = option_parser.parse_args()
+
+ # Initialize logging.
+ log_level = logging.INFO
+ if options.verbose:
+ log_level = logging.DEBUG
+ logging.basicConfig(level=log_level,
+ format='%(asctime)s %(filename)s:%(lineno)-3d'
+ ' %(levelname)s %(message)s',
+ datefmt='%y%m%d %H:%M:%S')
+ logging.basicConfig(level=logging.DEBUG)
+ logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))
+
+ if not options.perf_dashboard_id:
+ options.perf_dashboard_id = options.factory_properties.get('test_name')
+
+ options.test_type = options.test_type or options.factory_properties.get(
+ 'step_name', '')
+
+ if options.run_shell_script and options.run_python_script:
+ sys.stderr.write('Use either --run-shell-script OR --run-python-script, '
+ 'not both.')
+ return 1
+
+ print '[Running on builder: "%s"]' % options.builder_name
+
+ did_launch_dbus = False
+ if not options.no_spawn_dbus:
+ did_launch_dbus = _LaunchDBus()
+
+ try:
+ options.build_dir = build_directory.GetBuildOutputDirectory()
+
+ if options.pass_target and options.target:
+ args.extend(['--target', options.target])
+ if options.pass_build_dir:
+ args.extend(['--build-dir', options.build_dir])
+
+ # We will use this to accumulate overrides for the command under test,
+ # That we may not need or want for other support commands.
+ extra_env = {}
+
+ # This option is used by sanitizer code. There is no corresponding command
+ # line flag.
+ options.use_symbolization_script = False
+ # Set up extra environment and args for sanitizer tools.
+ _ConfigureSanitizerTools(options, args, extra_env)
+
+ # Set the number of shards environment variables.
+ # NOTE: Chromium's test launcher will ignore these in favor of the command
+ # line flags passed in _BuildTestBinaryCommand.
+ if options.total_shards and options.shard_index:
+ extra_env['GTEST_TOTAL_SHARDS'] = str(options.total_shards)
+ extra_env['GTEST_SHARD_INDEX'] = str(options.shard_index - 1)
+
+ # If perf config is passed via command line, parse the string into a dict.
+ if options.perf_config:
+ try:
+ options.perf_config = ast.literal_eval(options.perf_config)
+ assert type(options.perf_config) is dict, (
+ 'Value of --perf-config couldn\'t be evaluated into a dict.')
+ except (exceptions.SyntaxError, ValueError):
+ option_parser.error('Failed to parse --perf-config value into a dict: '
+ '%s' % options.perf_config)
+ return 1
+
+ # Allow factory property 'perf_config' as well during a transition period.
+ options.perf_config = (options.perf_config or
+ options.factory_properties.get('perf_config'))
+
+ if options.results_directory:
+ options.test_output_xml = os.path.normpath(os.path.abspath(os.path.join(
+ options.results_directory, '%s.xml' % options.test_type)))
+ args.append('--gtest_output=xml:' + options.test_output_xml)
+ elif options.generate_json_file:
+ option_parser.error(
+ '--results-directory is required with --generate-json-file=True')
+ return 1
+
+ if options.factory_properties.get('coverage_gtest_exclusions', False):
+ _BuildCoverageGtestExclusions(options, args)
+
+ temp_files = _GetTempCount()
+ if options.parse_input:
+ result = _MainParse(options, args)
+ elif sys.platform.startswith('darwin'):
+ test_platform = options.factory_properties.get(
+ 'test_platform', options.test_platform)
+ if test_platform in ('ios-simulator',):
+ result = _MainIOS(options, args, extra_env)
+ else:
+ result = _MainMac(options, args, extra_env)
+ elif sys.platform == 'win32':
+ result = _MainWin(options, args, extra_env)
+ elif sys.platform == 'linux2':
+ if options.factory_properties.get('test_platform',
+ options.test_platform) == 'android':
+ result = _MainAndroid(options, args, extra_env)
+ else:
+ result = _MainLinux(options, args, extra_env)
+ else:
+ sys.stderr.write('Unknown sys.platform value %s\n' % repr(sys.platform))
+ return 1
+
+ _UploadProfilingData(options, args)
+
+ new_temp_files = _GetTempCount()
+ if temp_files > new_temp_files:
+ print >> sys.stderr, (
+ 'Confused: %d files were deleted from %s during the test run') % (
+ (temp_files - new_temp_files), tempfile.gettempdir())
+ elif temp_files < new_temp_files:
+ print >> sys.stderr, (
+ '%d new files were left in %s: Fix the tests to clean up themselves.'
+ ) % ((new_temp_files - temp_files), tempfile.gettempdir())
+ # TODO(maruel): Make it an error soon. Not yet since I want to iron
+ # out all the remaining cases before.
+ #result = 1
+ return result
+ finally:
+ if did_launch_dbus:
+ # It looks like the command line argument --exit-with-session
+ # isn't working to clean up the spawned dbus-daemon. Kill it
+ # manually.
+ _ShutdownDBus()
+
+
+if '__main__' == __name__:
+ sys.exit(main())
« no previous file with comments | « infra/scripts/legacy/scripts/slave/runisolatedtest.py ('k') | infra/scripts/legacy/scripts/slave/slave_utils.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698