1 # Copyright (c) 2010 Google Inc. All rights reserved. 2 # 3 # Redistribution and use in source and binary forms, with or without 4 # modification, are permitted provided that the following conditions are 5 # met: 6 # 7 # * Redistributions of source code must retain the above copyright 8 # notice, this list of conditions and the following disclaimer. 9 # * Redistributions in binary form must reproduce the above 10 # copyright notice, this list of conditions and the following disclaimer 11 # in the documentation and/or other materials provided with the 12 # distribution. 13 # * Neither the name of Google Inc. nor the names of its 14 # contributors may be used to endorse or promote products derived from 15 # this software without specific prior written permission. 16 # 17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 29 """Starts a local HTTP server which displays layout test failures (given a test 30 results directory), provides comparisons of expected and actual results (both 31 images and text) and allows one-click rebaselining of tests.""" 32 from __future__ import with_statement 33 34 import codecs 35 import datetime 36 import fnmatch 37 import mimetypes 38 import os 39 import os.path 40 import shutil 41 import threading 42 import time 43 import urlparse 44 import BaseHTTPServer 45 46 from optparse import make_option 47 from wsgiref.handlers import format_date_time 48 49 from webkitpy.common import system 50 from webkitpy.layout_tests.layout_package import json_results_generator 51 from webkitpy.layout_tests.port import factory 52 from webkitpy.layout_tests.port.webkit import WebKitPort 53 from webkitpy.tool.multicommandtool import AbstractDeclarativeCommand 54 from webkitpy.thirdparty import simplejson 55 56 STATE_NEEDS_REBASELINE = 'needs_rebaseline' 57 STATE_REBASELINE_FAILED = 'rebaseline_failed' 58 STATE_REBASELINE_SUCCEEDED = 'rebaseline_succeeded' 59 60 class RebaselineHTTPServer(BaseHTTPServer.HTTPServer): 61 def __init__(self, httpd_port, test_config, results_json, platforms_json): 62 BaseHTTPServer.HTTPServer.__init__(self, ("", httpd_port), RebaselineHTTPRequestHandler) 63 self.test_config = test_config 64 self.results_json = results_json 65 self.platforms_json = platforms_json 66 67 68 class RebaselineHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): 69 STATIC_FILE_NAMES = frozenset([ 70 "index.html", 71 "loupe.js", 72 "main.js", 73 "main.css", 74 "queue.js", 75 "util.js", 76 ]) 77 78 STATIC_FILE_DIRECTORY = os.path.join( 79 os.path.dirname(__file__), "data", "rebaselineserver") 80 81 def do_GET(self): 82 self._handle_request() 83 84 def do_POST(self): 85 self._handle_request() 86 87 def _handle_request(self): 88 # Parse input. 89 if "?" in self.path: 90 path, query_string = self.path.split("?", 1) 91 self.query = urlparse.parse_qs(query_string) 92 else: 93 path = self.path 94 self.query = {} 95 function_or_file_name = path[1:] or "index.html" 96 97 # See if a static file matches. 98 if function_or_file_name in RebaselineHTTPRequestHandler.STATIC_FILE_NAMES: 99 self._serve_static_file(function_or_file_name) 100 return 101 102 # See if a class method matches. 103 function_name = function_or_file_name.replace(".", "_") 104 if not hasattr(self, function_name): 105 self.send_error(404, "Unknown function %s" % function_name) 106 return 107 if function_name[0] == "_": 108 self.send_error( 109 401, "Not allowed to invoke private or protected methods") 110 return 111 function = getattr(self, function_name) 112 function() 113 114 def _serve_static_file(self, static_path): 115 self._serve_file(os.path.join( 116 RebaselineHTTPRequestHandler.STATIC_FILE_DIRECTORY, static_path)) 117 118 def rebaseline(self): 119 test = self.query['test'][0] 120 baseline_target = self.query['baseline-target'][0] 121 baseline_move_to = self.query['baseline-move-to'][0] 122 test_json = self.server.results_json['tests'][test] 123 124 if test_json['state'] != STATE_NEEDS_REBASELINE: 125 self.send_error(400, "Test %s is in unexpected state: %s" % 126 (test, test_json["state"])) 127 return 128 129 log = [] 130 success = _rebaseline_test( 131 test, 132 baseline_target, 133 baseline_move_to, 134 self.server.test_config, 135 log=lambda l: log.append(l)) 136 137 if success: 138 test_json['state'] = STATE_REBASELINE_SUCCEEDED 139 self.send_response(200) 140 else: 141 test_json['state'] = STATE_REBASELINE_FAILED 142 self.send_response(500) 143 144 self.send_header('Content-type', 'text/plain') 145 self.end_headers() 146 self.wfile.write('\n'.join(log)) 147 148 def quitquitquit(self): 149 self.send_response(200) 150 self.send_header("Content-type", "text/plain") 151 self.end_headers() 152 self.wfile.write("Quit.\n") 153 154 # Shutdown has to happen on another thread from the server's thread, 155 # otherwise there's a deadlock 156 threading.Thread(target=lambda: self.server.shutdown()).start() 157 158 def test_result(self): 159 test_name, _ = os.path.splitext(self.query['test'][0]) 160 mode = self.query['mode'][0] 161 if mode == 'expected-image': 162 file_name = test_name + '-expected.png' 163 elif mode == 'actual-image': 164 file_name = test_name + '-actual.png' 165 if mode == 'expected-checksum': 166 file_name = test_name + '-expected.checksum' 167 elif mode == 'actual-checksum': 168 file_name = test_name + '-actual.checksum' 169 elif mode == 'diff-image': 170 file_name = test_name + '-diff.png' 171 if mode == 'expected-text': 172 file_name = test_name + '-expected.txt' 173 elif mode == 'actual-text': 174 file_name = test_name + '-actual.txt' 175 elif mode == 'diff-text': 176 file_name = test_name + '-diff.txt' 177 elif mode == 'diff-text-pretty': 178 file_name = test_name + '-pretty-diff.html' 179 180 file_path = os.path.join(self.server.test_config.results_directory, file_name) 181 182 # Let results be cached for 60 seconds, so that they can be pre-fetched 183 # by the UI 184 self._serve_file(file_path, cacheable_seconds=60) 185 186 def results_json(self): 187 self._serve_json(self.server.results_json) 188 189 def platforms_json(self): 190 self._serve_json(self.server.platforms_json) 191 192 def _serve_json(self, json): 193 self.send_response(200) 194 self.send_header('Content-type', 'application/json') 195 self.end_headers() 196 simplejson.dump(json, self.wfile) 197 198 def _serve_file(self, file_path, cacheable_seconds=0): 199 if not os.path.exists(file_path): 200 self.send_error(404, "File not found") 201 return 202 with codecs.open(file_path, "rb") as static_file: 203 self.send_response(200) 204 self.send_header("Content-Length", os.path.getsize(file_path)) 205 mime_type, encoding = mimetypes.guess_type(file_path) 206 if mime_type: 207 self.send_header("Content-type", mime_type) 208 209 if cacheable_seconds: 210 expires_time = (datetime.datetime.now() + 211 datetime.timedelta(0, cacheable_seconds)) 212 expires_formatted = format_date_time( 213 time.mktime(expires_time.timetuple())) 214 self.send_header("Expires", expires_formatted) 215 self.end_headers() 216 217 shutil.copyfileobj(static_file, self.wfile) 218 219 220 class TestConfig(object): 221 def __init__(self, test_port, layout_tests_directory, results_directory, platforms, filesystem, scm): 222 self.test_port = test_port 223 self.layout_tests_directory = layout_tests_directory 224 self.results_directory = results_directory 225 self.platforms = platforms 226 self.filesystem = filesystem 227 self.scm = scm 228 229 230 def _get_actual_result_files(test_file, test_config): 231 test_name, _ = os.path.splitext(test_file) 232 test_directory = os.path.dirname(test_file) 233 234 test_results_directory = test_config.filesystem.join( 235 test_config.results_directory, test_directory) 236 actual_pattern = os.path.basename(test_name) + '-actual.*' 237 actual_files = [] 238 for filename in test_config.filesystem.listdir(test_results_directory): 239 if fnmatch.fnmatch(filename, actual_pattern): 240 actual_files.append(filename) 241 actual_files.sort() 242 return tuple(actual_files) 243 244 245 def _rebaseline_test(test_file, baseline_target, baseline_move_to, test_config, log): 246 test_name, _ = os.path.splitext(test_file) 247 test_directory = os.path.dirname(test_name) 248 249 log('Rebaselining %s...' % test_name) 250 251 actual_result_files = _get_actual_result_files(test_file, test_config) 252 filesystem = test_config.filesystem 253 scm = test_config.scm 254 layout_tests_directory = test_config.layout_tests_directory 255 results_directory = test_config.results_directory 256 target_expectations_directory = filesystem.join( 257 layout_tests_directory, 'platform', baseline_target, test_directory) 258 test_results_directory = test_config.filesystem.join( 259 test_config.results_directory, test_directory) 260 261 # If requested, move current baselines out 262 current_baselines = _get_test_baselines(test_file, test_config) 263 if baseline_target in current_baselines and baseline_move_to != 'none': 264 log(' Moving current %s baselines to %s' % 265 (baseline_target, baseline_move_to)) 266 267 # See which ones we need to move (only those that are about to be 268 # updated), and make sure we're not clobbering any files in the 269 # destination. 270 current_extensions = set(current_baselines[baseline_target].keys()) 271 actual_result_extensions = [ 272 os.path.splitext(f)[1] for f in actual_result_files] 273 extensions_to_move = current_extensions.intersection( 274 actual_result_extensions) 275 276 if extensions_to_move.intersection( 277 current_baselines.get(baseline_move_to, {}).keys()): 278 log(' Already had baselines in %s, could not move existing ' 279 '%s ones' % (baseline_move_to, baseline_target)) 280 return False 281 282 # Do the actual move. 283 if extensions_to_move: 284 if not _move_test_baselines( 285 test_file, 286 list(extensions_to_move), 287 baseline_target, 288 baseline_move_to, 289 test_config, 290 log): 291 return False 292 else: 293 log(' No current baselines to move') 294 295 log(' Updating baselines for %s' % baseline_target) 296 filesystem.maybe_make_directory(target_expectations_directory) 297 for source_file in actual_result_files: 298 source_path = filesystem.join(test_results_directory, source_file) 299 destination_file = source_file.replace('-actual', '-expected') 300 destination_path = filesystem.join( 301 target_expectations_directory, destination_file) 302 filesystem.copyfile(source_path, destination_path) 303 exit_code = scm.add(destination_path, return_exit_code=True) 304 if exit_code: 305 log(' Could not update %s in SCM, exit code %d' % 306 (destination_file, exit_code)) 307 return False 308 else: 309 log(' Updated %s' % destination_file) 310 311 return True 312 313 314 def _move_test_baselines(test_file, extensions_to_move, source_platform, destination_platform, test_config, log): 315 test_file_name = os.path.splitext(os.path.basename(test_file))[0] 316 test_directory = os.path.dirname(test_file) 317 filesystem = test_config.filesystem 318 319 # Want predictable output order for unit tests. 320 extensions_to_move.sort() 321 322 source_directory = os.path.join( 323 test_config.layout_tests_directory, 324 'platform', 325 source_platform, 326 test_directory) 327 destination_directory = os.path.join( 328 test_config.layout_tests_directory, 329 'platform', 330 destination_platform, 331 test_directory) 332 filesystem.maybe_make_directory(destination_directory) 333 334 for extension in extensions_to_move: 335 file_name = test_file_name + '-expected' + extension 336 source_path = filesystem.join(source_directory, file_name) 337 destination_path = filesystem.join(destination_directory, file_name) 338 filesystem.copyfile(source_path, destination_path) 339 exit_code = test_config.scm.add(destination_path, return_exit_code=True) 340 if exit_code: 341 log(' Could not update %s in SCM, exit code %d' % 342 (file_name, exit_code)) 343 return False 344 else: 345 log(' Moved %s' % file_name) 346 347 return True 348 349 def _get_test_baselines(test_file, test_config): 350 class AllPlatformsPort(WebKitPort): 351 def __init__(self): 352 WebKitPort.__init__(self, filesystem=test_config.filesystem) 353 self._platforms_by_directory = dict( 354 [(self._webkit_baseline_path(p), p) for p in test_config.platforms]) 355 356 def baseline_search_path(self): 357 return self._platforms_by_directory.keys() 358 359 def platform_from_directory(self, directory): 360 return self._platforms_by_directory[directory] 361 362 test_path = test_config.filesystem.join( 363 test_config.layout_tests_directory, test_file) 364 365 all_platforms_port = AllPlatformsPort() 366 367 all_test_baselines = {} 368 for baseline_extension in ('.txt', '.checksum', '.png'): 369 test_baselines = test_config.test_port.expected_baselines( 370 test_path, baseline_extension) 371 baselines = all_platforms_port.expected_baselines( 372 test_path, baseline_extension, all_baselines=True) 373 for platform_directory, expected_filename in baselines: 374 if not platform_directory: 375 continue 376 if platform_directory == test_config.layout_tests_directory: 377 platform = 'base' 378 else: 379 platform = all_platforms_port.platform_from_directory( 380 platform_directory) 381 platform_baselines = all_test_baselines.setdefault(platform, {}) 382 was_used_for_test = ( 383 platform_directory, expected_filename) in test_baselines 384 platform_baselines[baseline_extension] = was_used_for_test 385 386 return all_test_baselines 387 388 389 class RebaselineServer(AbstractDeclarativeCommand): 390 name = "rebaseline-server" 391 help_text = __doc__ 392 argument_names = "/path/to/results/directory" 393 394 def __init__(self): 395 options = [ 396 make_option("--httpd-port", action="store", type="int", default=8127, help="Port to use for the the rebaseline HTTP server"), 397 ] 398 AbstractDeclarativeCommand.__init__(self, options=options) 399 400 def execute(self, options, args, tool): 401 results_directory = args[0] 402 filesystem = system.filesystem.FileSystem() 403 scm = self._tool.scm() 404 405 if options.dry_run: 406 407 def no_op_copyfile(src, dest): 408 pass 409 410 def no_op_add(path, return_exit_code=False): 411 if return_exit_code: 412 return 0 413 414 filesystem.copyfile = no_op_copyfile 415 scm.add = no_op_add 416 417 print 'Parsing unexpected_results.json...' 418 results_json_path = filesystem.join(results_directory, 'unexpected_results.json') 419 results_json = json_results_generator.load_json(filesystem, results_json_path) 420 421 port = factory.get() 422 layout_tests_directory = port.layout_tests_dir() 423 platforms = filesystem.listdir( 424 filesystem.join(layout_tests_directory, 'platform')) 425 test_config = TestConfig( 426 port, 427 layout_tests_directory, 428 results_directory, 429 platforms, 430 filesystem, 431 scm) 432 433 print 'Gathering current baselines...' 434 for test_file, test_json in results_json['tests'].items(): 435 test_json['state'] = STATE_NEEDS_REBASELINE 436 test_path = filesystem.join(layout_tests_directory, test_file) 437 test_json['baselines'] = _get_test_baselines(test_file, test_config) 438 439 server_url = "http://localhost:%d/" % options.httpd_port 440 print "Starting server at %s" % server_url 441 print ("Use the 'Exit' link in the UI, %squitquitquit " 442 "or Ctrl-C to stop") % server_url 443 444 threading.Timer( 445 .1, lambda: self._tool.user.open_url(server_url)).start() 446 447 httpd = RebaselineHTTPServer( 448 httpd_port=options.httpd_port, 449 test_config=test_config, 450 results_json=results_json, 451 platforms_json={ 452 'platforms': platforms, 453 'defaultPlatform': port.name(), 454 }) 455 httpd.serve_forever() 456