# Copyright (c) 2010 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. """Starts a local HTTP server which displays layout test failures (given a test results directory), provides comparisons of expected and actual results (both images and text) and allows one-click rebaselining of tests.""" from __future__ import with_statement import codecs import datetime import fnmatch import mimetypes import os import os.path import shutil import threading import time import urlparse import BaseHTTPServer from optparse import make_option from wsgiref.handlers import format_date_time from webkitpy.common import system from webkitpy.layout_tests.layout_package import json_results_generator from webkitpy.layout_tests.port import factory from webkitpy.layout_tests.port.webkit import WebKitPort from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand from webkitpy.thirdparty import simplejson STATE_NEEDS_REBASELINE = 'needs_rebaseline' STATE_REBASELINE_FAILED = 'rebaseline_failed' STATE_REBASELINE_SUCCEEDED = 'rebaseline_succeeded' class RebaselineHTTPServer(BaseHTTPServer.HTTPServer): def __init__(self, httpd_port, test_config, results_json, platforms_json): BaseHTTPServer.HTTPServer.__init__(self, ("", httpd_port), RebaselineHTTPRequestHandler) self.test_config = test_config self.results_json = results_json self.platforms_json = platforms_json class RebaselineHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): STATIC_FILE_NAMES = frozenset([ "index.html", "loupe.js", "main.js", "main.css", "queue.js", "util.js", ]) STATIC_FILE_DIRECTORY = os.path.join( os.path.dirname(__file__), "data", "rebaselineserver") def do_GET(self): self._handle_request() def do_POST(self): self._handle_request() def _handle_request(self): # Parse input. if "?" in self.path: path, query_string = self.path.split("?", 1) self.query = urlparse.parse_qs(query_string) else: path = self.path self.query = {} function_or_file_name = path[1:] or "index.html" # See if a static file matches. if function_or_file_name in RebaselineHTTPRequestHandler.STATIC_FILE_NAMES: self._serve_static_file(function_or_file_name) return # See if a class method matches. function_name = function_or_file_name.replace(".", "_") if not hasattr(self, function_name): self.send_error(404, "Unknown function %s" % function_name) return if function_name[0] == "_": self.send_error( 401, "Not allowed to invoke private or protected methods") return function = getattr(self, function_name) function() def _serve_static_file(self, static_path): self._serve_file(os.path.join( RebaselineHTTPRequestHandler.STATIC_FILE_DIRECTORY, static_path)) def rebaseline(self): test = self.query['test'][0] baseline_target = self.query['baseline-target'][0] baseline_move_to = self.query['baseline-move-to'][0] test_json = self.server.results_json['tests'][test] if test_json['state'] != STATE_NEEDS_REBASELINE: self.send_error(400, "Test %s is in unexpected state: %s" % (test, test_json["state"])) return log = [] success = _rebaseline_test( test, baseline_target, baseline_move_to, self.server.test_config, log=lambda l: log.append(l)) if success: test_json['state'] = STATE_REBASELINE_SUCCEEDED self.send_response(200) else: test_json['state'] = STATE_REBASELINE_FAILED self.send_response(500) self.send_header('Content-type', 'text/plain') self.end_headers() self.wfile.write('\n'.join(log)) def quitquitquit(self): self.send_response(200) self.send_header("Content-type", "text/plain") self.end_headers() self.wfile.write("Quit.\n") # Shutdown has to happen on another thread from the server's thread, # otherwise there's a deadlock threading.Thread(target=lambda: self.server.shutdown()).start() def test_result(self): test_name, _ = os.path.splitext(self.query['test'][0]) mode = self.query['mode'][0] if mode == 'expected-image': file_name = test_name + '-expected.png' elif mode == 'actual-image': file_name = test_name + '-actual.png' if mode == 'expected-checksum': file_name = test_name + '-expected.checksum' elif mode == 'actual-checksum': file_name = test_name + '-actual.checksum' elif mode == 'diff-image': file_name = test_name + '-diff.png' if mode == 'expected-text': file_name = test_name + '-expected.txt' elif mode == 'actual-text': file_name = test_name + '-actual.txt' elif mode == 'diff-text': file_name = test_name + '-diff.txt' elif mode == 'diff-text-pretty': file_name = test_name + '-pretty-diff.html' file_path = os.path.join(self.server.test_config.results_directory, file_name) # Let results be cached for 60 seconds, so that they can be pre-fetched # by the UI self._serve_file(file_path, cacheable_seconds=60) def results_json(self): self._serve_json(self.server.results_json) def platforms_json(self): self._serve_json(self.server.platforms_json) def _serve_json(self, json): self.send_response(200) self.send_header('Content-type', 'application/json') self.end_headers() simplejson.dump(json, self.wfile) def _serve_file(self, file_path, cacheable_seconds=0): if not os.path.exists(file_path): self.send_error(404, "File not found") return with codecs.open(file_path, "rb") as static_file: self.send_response(200) self.send_header("Content-Length", os.path.getsize(file_path)) mime_type, encoding = mimetypes.guess_type(file_path) if mime_type: self.send_header("Content-type", mime_type) if cacheable_seconds: expires_time = (datetime.datetime.now() + datetime.timedelta(0, cacheable_seconds)) expires_formatted = format_date_time( time.mktime(expires_time.timetuple())) self.send_header("Expires", expires_formatted) self.end_headers() shutil.copyfileobj(static_file, self.wfile) class TestConfig(object): def __init__(self, test_port, layout_tests_directory, results_directory, platforms, filesystem, scm): self.test_port = test_port self.layout_tests_directory = layout_tests_directory self.results_directory = results_directory self.platforms = platforms self.filesystem = filesystem self.scm = scm def _get_actual_result_files(test_file, test_config): test_name, _ = os.path.splitext(test_file) test_directory = os.path.dirname(test_file) test_results_directory = test_config.filesystem.join( test_config.results_directory, test_directory) actual_pattern = os.path.basename(test_name) + '-actual.*' actual_files = [] for filename in test_config.filesystem.listdir(test_results_directory): if fnmatch.fnmatch(filename, actual_pattern): actual_files.append(filename) actual_files.sort() return tuple(actual_files) def _rebaseline_test(test_file, baseline_target, baseline_move_to, test_config, log): test_name, _ = os.path.splitext(test_file) test_directory = os.path.dirname(test_name) log('Rebaselining %s...' % test_name) actual_result_files = _get_actual_result_files(test_file, test_config) filesystem = test_config.filesystem scm = test_config.scm layout_tests_directory = test_config.layout_tests_directory results_directory = test_config.results_directory target_expectations_directory = filesystem.join( layout_tests_directory, 'platform', baseline_target, test_directory) test_results_directory = test_config.filesystem.join( test_config.results_directory, test_directory) # If requested, move current baselines out current_baselines = _get_test_baselines(test_file, test_config) if baseline_target in current_baselines and baseline_move_to != 'none': log(' Moving current %s baselines to %s' % (baseline_target, baseline_move_to)) # See which ones we need to move (only those that are about to be # updated), and make sure we're not clobbering any files in the # destination. current_extensions = set(current_baselines[baseline_target].keys()) actual_result_extensions = [ os.path.splitext(f)[1] for f in actual_result_files] extensions_to_move = current_extensions.intersection( actual_result_extensions) if extensions_to_move.intersection( current_baselines.get(baseline_move_to, {}).keys()): log(' Already had baselines in %s, could not move existing ' '%s ones' % (baseline_move_to, baseline_target)) return False # Do the actual move. if extensions_to_move: if not _move_test_baselines( test_file, list(extensions_to_move), baseline_target, baseline_move_to, test_config, log): return False else: log(' No current baselines to move') log(' Updating baselines for %s' % baseline_target) filesystem.maybe_make_directory(target_expectations_directory) for source_file in actual_result_files: source_path = filesystem.join(test_results_directory, source_file) destination_file = source_file.replace('-actual', '-expected') destination_path = filesystem.join( target_expectations_directory, destination_file) filesystem.copyfile(source_path, destination_path) exit_code = scm.add(destination_path, return_exit_code=True) if exit_code: log(' Could not update %s in SCM, exit code %d' % (destination_file, exit_code)) return False else: log(' Updated %s' % destination_file) return True def _move_test_baselines(test_file, extensions_to_move, source_platform, destination_platform, test_config, log): test_file_name = os.path.splitext(os.path.basename(test_file))[0] test_directory = os.path.dirname(test_file) filesystem = test_config.filesystem # Want predictable output order for unit tests. extensions_to_move.sort() source_directory = os.path.join( test_config.layout_tests_directory, 'platform', source_platform, test_directory) destination_directory = os.path.join( test_config.layout_tests_directory, 'platform', destination_platform, test_directory) filesystem.maybe_make_directory(destination_directory) for extension in extensions_to_move: file_name = test_file_name + '-expected' + extension source_path = filesystem.join(source_directory, file_name) destination_path = filesystem.join(destination_directory, file_name) filesystem.copyfile(source_path, destination_path) exit_code = test_config.scm.add(destination_path, return_exit_code=True) if exit_code: log(' Could not update %s in SCM, exit code %d' % (file_name, exit_code)) return False else: log(' Moved %s' % file_name) return True def _get_test_baselines(test_file, test_config): class AllPlatformsPort(WebKitPort): def __init__(self): WebKitPort.__init__(self, filesystem=test_config.filesystem) self._platforms_by_directory = dict( [(self._webkit_baseline_path(p), p) for p in test_config.platforms]) def baseline_search_path(self): return self._platforms_by_directory.keys() def platform_from_directory(self, directory): return self._platforms_by_directory[directory] test_path = test_config.filesystem.join( test_config.layout_tests_directory, test_file) all_platforms_port = AllPlatformsPort() all_test_baselines = {} for baseline_extension in ('.txt', '.checksum', '.png'): test_baselines = test_config.test_port.expected_baselines( test_path, baseline_extension) baselines = all_platforms_port.expected_baselines( test_path, baseline_extension, all_baselines=True) for platform_directory, expected_filename in baselines: if not platform_directory: continue if platform_directory == test_config.layout_tests_directory: platform = 'base' else: platform = all_platforms_port.platform_from_directory( platform_directory) platform_baselines = all_test_baselines.setdefault(platform, {}) was_used_for_test = ( platform_directory, expected_filename) in test_baselines platform_baselines[baseline_extension] = was_used_for_test return all_test_baselines class RebaselineServer(AbstractDeclarativeCommand): name = "rebaseline-server" help_text = __doc__ argument_names = "/path/to/results/directory" def __init__(self): options = [ make_option("--httpd-port", action="store", type="int", default=8127, help="Port to use for the the rebaseline HTTP server"), ] AbstractDeclarativeCommand.__init__(self, options=options) def execute(self, options, args, tool): results_directory = args[0] filesystem = system.filesystem.FileSystem() scm = self._tool.scm() if options.dry_run: def no_op_copyfile(src, dest): pass def no_op_add(path, return_exit_code=False): if return_exit_code: return 0 filesystem.copyfile = no_op_copyfile scm.add = no_op_add print 'Parsing unexpected_results.json...' results_json_path = filesystem.join(results_directory, 'unexpected_results.json') results_json = json_results_generator.load_json(filesystem, results_json_path) port = factory.get() layout_tests_directory = port.layout_tests_dir() platforms = filesystem.listdir( filesystem.join(layout_tests_directory, 'platform')) test_config = TestConfig( port, layout_tests_directory, results_directory, platforms, filesystem, scm) print 'Gathering current baselines...' for test_file, test_json in results_json['tests'].items(): test_json['state'] = STATE_NEEDS_REBASELINE test_path = filesystem.join(layout_tests_directory, test_file) test_json['baselines'] = _get_test_baselines(test_file, test_config) server_url = "http://localhost:%d/" % options.httpd_port print "Starting server at %s" % server_url print ("Use the 'Exit' link in the UI, %squitquitquit " "or Ctrl-C to stop") % server_url threading.Timer( .1, lambda: self._tool.user.open_url(server_url)).start() httpd = RebaselineHTTPServer( httpd_port=options.httpd_port, test_config=test_config, results_json=results_json, platforms_json={ 'platforms': platforms, 'defaultPlatform': port.name(), }) httpd.serve_forever()