Home | History | Annotate | Download | only in port
      1 #!/usr/bin/env python
      2 # Copyright (C) 2010 Google Inc. All rights reserved.
      3 # Copyright (C) 2010 Gabor Rapcsanyi <rgabor (at] inf.u-szeged.hu>, University of Szeged
      4 #
      5 # Redistribution and use in source and binary forms, with or without
      6 # modification, are permitted provided that the following conditions are
      7 # met:
      8 #
      9 #     * Redistributions of source code must retain the above copyright
     10 # notice, this list of conditions and the following disclaimer.
     11 #     * Redistributions in binary form must reproduce the above
     12 # copyright notice, this list of conditions and the following disclaimer
     13 # in the documentation and/or other materials provided with the
     14 # distribution.
     15 #     * Neither the Google name nor the names of its
     16 # contributors may be used to endorse or promote products derived from
     17 # this software without specific prior written permission.
     18 #
     19 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
     20 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
     21 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
     22 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
     23 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
     24 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
     25 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
     26 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
     27 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     28 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
     29 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
     30 
     31 """WebKit implementations of the Port interface."""
     32 
     33 import base64
     34 import logging
     35 import operator
     36 import os
     37 import re
     38 import signal
     39 import sys
     40 import time
     41 import webbrowser
     42 
     43 from webkitpy.common.system import ospath
     44 from webkitpy.layout_tests.port import base
     45 from webkitpy.layout_tests.port import server_process
     46 
     47 _log = logging.getLogger("webkitpy.layout_tests.port.webkit")
     48 
     49 
     50 class WebKitPort(base.Port):
     51     """WebKit implementation of the Port class."""
     52 
     53     def __init__(self, **kwargs):
     54         base.Port.__init__(self, **kwargs)
     55         self._cached_apache_path = None
     56 
     57         # FIXME: disable pixel tests until they are run by default on the
     58         # build machines.
     59         if not hasattr(self._options, "pixel_tests") or self._options.pixel_tests == None:
     60             self._options.pixel_tests = False
     61 
     62     def baseline_path(self):
     63         return self._webkit_baseline_path(self._name)
     64 
     65     def baseline_search_path(self):
     66         return [self._webkit_baseline_path(self._name)]
     67 
     68     def path_to_test_expectations_file(self):
     69         return self._filesystem.join(self._webkit_baseline_path(self._name),
     70                                      'test_expectations.txt')
     71 
     72     def _build_driver(self):
     73         configuration = self.get_option('configuration')
     74         return self._config.build_dumprendertree(configuration)
     75 
     76     def _check_driver(self):
     77         driver_path = self._path_to_driver()
     78         if not self._filesystem.exists(driver_path):
     79             _log.error("DumpRenderTree was not found at %s" % driver_path)
     80             return False
     81         return True
     82 
     83     def check_build(self, needs_http):
     84         if self.get_option('build') and not self._build_driver():
     85             return False
     86         if not self._check_driver():
     87             return False
     88         if self.get_option('pixel_tests'):
     89             if not self.check_image_diff():
     90                 return False
     91         if not self._check_port_build():
     92             return False
     93         return True
     94 
     95     def _check_port_build(self):
     96         # Ports can override this method to do additional checks.
     97         return True
     98 
     99     def check_image_diff(self, override_step=None, logging=True):
    100         image_diff_path = self._path_to_image_diff()
    101         if not self._filesystem.exists(image_diff_path):
    102             _log.error("ImageDiff was not found at %s" % image_diff_path)
    103             return False
    104         return True
    105 
    106     def diff_image(self, expected_contents, actual_contents,
    107                    diff_filename=None):
    108         """Return True if the two files are different. Also write a delta
    109         image of the two images into |diff_filename| if it is not None."""
    110 
    111         # Handle the case where the test didn't actually generate an image.
    112         # FIXME: need unit tests for this.
    113         if not actual_contents and not expected_contents:
    114             return False
    115         if not actual_contents or not expected_contents:
    116             return True
    117 
    118         sp = self._diff_image_request(expected_contents, actual_contents)
    119         return self._diff_image_reply(sp, diff_filename)
    120 
    121     def _diff_image_request(self, expected_contents, actual_contents):
    122         # FIXME: There needs to be a more sane way of handling default
    123         # values for options so that you can distinguish between a default
    124         # value of None and a default value that wasn't set.
    125         if self.get_option('tolerance') is not None:
    126             tolerance = self.get_option('tolerance')
    127         else:
    128             tolerance = 0.1
    129         command = [self._path_to_image_diff(), '--tolerance', str(tolerance)]
    130         sp = server_process.ServerProcess(self, 'ImageDiff', command)
    131 
    132         sp.write('Content-Length: %d\n%sContent-Length: %d\n%s' %
    133                  (len(actual_contents), actual_contents,
    134                   len(expected_contents), expected_contents))
    135 
    136         return sp
    137 
    138     def _diff_image_reply(self, sp, diff_filename):
    139         timeout = 2.0
    140         deadline = time.time() + timeout
    141         output = sp.read_line(timeout)
    142         while not sp.timed_out and not sp.crashed and output:
    143             if output.startswith('Content-Length'):
    144                 m = re.match('Content-Length: (\d+)', output)
    145                 content_length = int(m.group(1))
    146                 timeout = deadline - time.time()
    147                 output = sp.read(timeout, content_length)
    148                 break
    149             elif output.startswith('diff'):
    150                 break
    151             else:
    152                 timeout = deadline - time.time()
    153                 output = sp.read_line(deadline)
    154 
    155         result = True
    156         if output.startswith('diff'):
    157             m = re.match('diff: (.+)% (passed|failed)', output)
    158             if m.group(2) == 'passed':
    159                 result = False
    160         elif output and diff_filename:
    161             self._filesystem.write_binary_file(diff_filename, output)
    162         elif sp.timed_out:
    163             _log.error("ImageDiff timed out")
    164         elif sp.crashed:
    165             _log.error("ImageDiff crashed")
    166         sp.stop()
    167         return result
    168 
    169     def default_results_directory(self):
    170         # Results are store relative to the built products to make it easy
    171         # to have multiple copies of webkit checked out and built.
    172         return self._build_path('layout-test-results')
    173 
    174     def setup_test_run(self):
    175         # This port doesn't require any specific configuration.
    176         pass
    177 
    178     def create_driver(self, worker_number):
    179         return WebKitDriver(self, worker_number)
    180 
    181     def _tests_for_other_platforms(self):
    182         # By default we will skip any directory under LayoutTests/platform
    183         # that isn't in our baseline search path (this mirrors what
    184         # old-run-webkit-tests does in findTestsToRun()).
    185         # Note this returns LayoutTests/platform/*, not platform/*/*.
    186         entries = self._filesystem.glob(self._webkit_baseline_path('*'))
    187         dirs_to_skip = []
    188         for entry in entries:
    189             if self._filesystem.isdir(entry) and not entry in self.baseline_search_path():
    190                 basename = self._filesystem.basename(entry)
    191                 dirs_to_skip.append('platform/%s' % basename)
    192         return dirs_to_skip
    193 
    194     def _runtime_feature_list(self):
    195         """Return the supported features of DRT. If a port doesn't support
    196         this DRT switch, it has to override this method to return None"""
    197         driver_path = self._path_to_driver()
    198         feature_list = ' '.join(os.popen(driver_path + " --print-supported-features 2>&1").readlines())
    199         if "SupportedFeatures:" in feature_list:
    200             return feature_list
    201         return None
    202 
    203     def _supported_symbol_list(self):
    204         """Return the supported symbols of WebCore."""
    205         webcore_library_path = self._path_to_webcore_library()
    206         if not webcore_library_path:
    207             return None
    208         symbol_list = ' '.join(os.popen("nm " + webcore_library_path).readlines())
    209         return symbol_list
    210 
    211     def _directories_for_features(self):
    212         """Return the supported feature dictionary. The keys are the
    213         features and the values are the directories in lists."""
    214         directories_for_features = {
    215             "Accelerated Compositing": ["compositing"],
    216             "3D Rendering": ["animations/3d", "transforms/3d"],
    217         }
    218         return directories_for_features
    219 
    220     def _directories_for_symbols(self):
    221         """Return the supported feature dictionary. The keys are the
    222         symbols and the values are the directories in lists."""
    223         directories_for_symbol = {
    224             "MathMLElement": ["mathml"],
    225             "GraphicsLayer": ["compositing"],
    226             "WebCoreHas3DRendering": ["animations/3d", "transforms/3d"],
    227             "WebGLShader": ["fast/canvas/webgl", "compositing/webgl", "http/tests/canvas/webgl"],
    228             "WMLElement": ["http/tests/wml", "fast/wml", "wml"],
    229             "parseWCSSInputProperty": ["fast/wcss"],
    230             "isXHTMLMPDocument": ["fast/xhtmlmp"],
    231         }
    232         return directories_for_symbol
    233 
    234     def _skipped_tests_for_unsupported_features(self):
    235         """Return the directories of unsupported tests. Search for the
    236         symbols in the symbol_list, if found add the corresponding
    237         directories to the skipped directory list."""
    238         feature_list = self._runtime_feature_list()
    239         directories = self._directories_for_features()
    240 
    241         # if DRT feature detection not supported
    242         if not feature_list:
    243             feature_list = self._supported_symbol_list()
    244             directories = self._directories_for_symbols()
    245 
    246         if not feature_list:
    247             return []
    248 
    249         skipped_directories = [directories[feature]
    250                               for feature in directories.keys()
    251                               if feature not in feature_list]
    252         return reduce(operator.add, skipped_directories)
    253 
    254     def _tests_for_disabled_features(self):
    255         # FIXME: This should use the feature detection from
    256         # webkitperl/features.pm to match run-webkit-tests.
    257         # For now we hard-code a list of features known to be disabled on
    258         # the Mac platform.
    259         disabled_feature_tests = [
    260             "fast/xhtmlmp",
    261             "http/tests/wml",
    262             "mathml",
    263             "wml",
    264         ]
    265         # FIXME: webarchive tests expect to read-write from
    266         # -expected.webarchive files instead of .txt files.
    267         # This script doesn't know how to do that yet, so pretend they're
    268         # just "disabled".
    269         webarchive_tests = [
    270             "webarchive",
    271             "svg/webarchive",
    272             "http/tests/webarchive",
    273             "svg/custom/image-with-prefix-in-webarchive.svg",
    274         ]
    275         unsupported_feature_tests = self._skipped_tests_for_unsupported_features()
    276         return disabled_feature_tests + webarchive_tests + unsupported_feature_tests
    277 
    278     def _tests_from_skipped_file_contents(self, skipped_file_contents):
    279         tests_to_skip = []
    280         for line in skipped_file_contents.split('\n'):
    281             line = line.strip()
    282             if line.startswith('#') or not len(line):
    283                 continue
    284             tests_to_skip.append(line)
    285         return tests_to_skip
    286 
    287     def _skipped_file_paths(self):
    288         return [self._filesystem.join(self._webkit_baseline_path(self._name), 'Skipped')]
    289 
    290     def _expectations_from_skipped_files(self):
    291         tests_to_skip = []
    292         for filename in self._skipped_file_paths():
    293             if not self._filesystem.exists(filename):
    294                 _log.warn("Failed to open Skipped file: %s" % filename)
    295                 continue
    296             skipped_file_contents = self._filesystem.read_text_file(filename)
    297             tests_to_skip.extend(self._tests_from_skipped_file_contents(skipped_file_contents))
    298         return tests_to_skip
    299 
    300     def test_expectations(self):
    301         # The WebKit mac port uses a combination of a test_expectations file
    302         # and 'Skipped' files.
    303         expectations_path = self.path_to_test_expectations_file()
    304         return self._filesystem.read_text_file(expectations_path) + self._skips()
    305 
    306     def _skips(self):
    307         # Each Skipped file contains a list of files
    308         # or directories to be skipped during the test run. The total list
    309         # of tests to skipped is given by the contents of the generic
    310         # Skipped file found in platform/X plus a version-specific file
    311         # found in platform/X-version. Duplicate entries are allowed.
    312         # This routine reads those files and turns contents into the
    313         # format expected by test_expectations.
    314 
    315         tests_to_skip = self.skipped_layout_tests()
    316         skip_lines = map(lambda test_path: "BUG_SKIPPED SKIP : %s = FAIL" %
    317                                 test_path, tests_to_skip)
    318         return "\n".join(skip_lines)
    319 
    320     def skipped_layout_tests(self):
    321         # Use a set to allow duplicates
    322         tests_to_skip = set(self._expectations_from_skipped_files())
    323         tests_to_skip.update(self._tests_for_other_platforms())
    324         tests_to_skip.update(self._tests_for_disabled_features())
    325         return tests_to_skip
    326 
    327     def _build_path(self, *comps):
    328         return self._filesystem.join(self._config.build_directory(
    329             self.get_option('configuration')), *comps)
    330 
    331     def _path_to_driver(self):
    332         return self._build_path('DumpRenderTree')
    333 
    334     def _path_to_webcore_library(self):
    335         return None
    336 
    337     def _path_to_helper(self):
    338         return None
    339 
    340     def _path_to_image_diff(self):
    341         return self._build_path('ImageDiff')
    342 
    343     def _path_to_wdiff(self):
    344         # FIXME: This does not exist on a default Mac OS X Leopard install.
    345         return 'wdiff'
    346 
    347     def _path_to_apache(self):
    348         if not self._cached_apache_path:
    349             # The Apache binary path can vary depending on OS and distribution
    350             # See http://wiki.apache.org/httpd/DistrosDefaultLayout
    351             for path in ["/usr/sbin/httpd", "/usr/sbin/apache2"]:
    352                 if self._filesystem.exists(path):
    353                     self._cached_apache_path = path
    354                     break
    355 
    356             if not self._cached_apache_path:
    357                 _log.error("Could not find apache. Not installed or unknown path.")
    358 
    359         return self._cached_apache_path
    360 
    361 
    362 class WebKitDriver(base.Driver):
    363     """WebKit implementation of the DumpRenderTree interface."""
    364 
    365     def __init__(self, port, worker_number):
    366         self._worker_number = worker_number
    367         self._port = port
    368         self._driver_tempdir = port._filesystem.mkdtemp(prefix='DumpRenderTree-')
    369 
    370     def __del__(self):
    371         self._port._filesystem.rmtree(str(self._driver_tempdir))
    372 
    373     def cmd_line(self):
    374         cmd = self._command_wrapper(self._port.get_option('wrapper'))
    375         cmd.append(self._port._path_to_driver())
    376         if self._port.get_option('pixel_tests'):
    377             cmd.append('--pixel-tests')
    378         cmd.extend(self._port.get_option('additional_drt_flag', []))
    379         cmd.append('-')
    380         return cmd
    381 
    382     def start(self):
    383         environment = self._port.setup_environ_for_server()
    384         environment['DYLD_FRAMEWORK_PATH'] = self._port._build_path()
    385         environment['DUMPRENDERTREE_TEMP'] = str(self._driver_tempdir)
    386         self._server_process = server_process.ServerProcess(self._port,
    387             "DumpRenderTree", self.cmd_line(), environment)
    388 
    389     def poll(self):
    390         return self._server_process.poll()
    391 
    392     def restart(self):
    393         self._server_process.stop()
    394         self._server_process.start()
    395         return
    396 
    397     # FIXME: This function is huge.
    398     def run_test(self, driver_input):
    399         uri = self._port.filename_to_uri(driver_input.filename)
    400         if uri.startswith("file:///"):
    401             command = uri[7:]
    402         else:
    403             command = uri
    404 
    405         if driver_input.image_hash:
    406             command += "'" + driver_input.image_hash
    407         command += "\n"
    408 
    409         start_time = time.time()
    410         self._server_process.write(command)
    411 
    412         text = None
    413         image = None
    414         actual_image_hash = None
    415         audio = None
    416         deadline = time.time() + int(driver_input.timeout) / 1000.0
    417 
    418         # First block is either text or audio
    419         block = self._read_block(deadline)
    420         if block.content_type == 'audio/wav':
    421             audio = block.decoded_content
    422         else:
    423             text = block.decoded_content
    424 
    425         # Now read an optional second block of image data
    426         block = self._read_block(deadline)
    427         if block.content and block.content_type == 'image/png':
    428             image = block.decoded_content
    429             actual_image_hash = block.content_hash
    430 
    431         error_lines = self._server_process.error.splitlines()
    432         # FIXME: This is a hack.  It is unclear why sometimes
    433         # we do not get any error lines from the server_process
    434         # probably we are not flushing stderr.
    435         if error_lines and error_lines[-1] == "#EOF":
    436             error_lines.pop()  # Remove the expected "#EOF"
    437         error = "\n".join(error_lines)
    438         # FIXME: This seems like the wrong section of code to be doing
    439         # this reset in.
    440         self._server_process.error = ""
    441         return base.DriverOutput(text, image, actual_image_hash, audio,
    442             crash=self._server_process.crashed, test_time=time.time() - start_time,
    443             timeout=self._server_process.timed_out, error=error)
    444 
    445     def _read_block(self, deadline):
    446         LENGTH_HEADER = 'Content-Length: '
    447         HASH_HEADER = 'ActualHash: '
    448         TYPE_HEADER = 'Content-Type: '
    449         ENCODING_HEADER = 'Content-Transfer-Encoding: '
    450         content_type = None
    451         encoding = None
    452         content_hash = None
    453         content_length = None
    454 
    455         # Content is treated as binary data even though the text output
    456         # is usually UTF-8.
    457         content = ''
    458         timeout = deadline - time.time()
    459         line = self._server_process.read_line(timeout)
    460         while (not self._server_process.timed_out
    461                and not self._server_process.crashed
    462                and line.rstrip() != "#EOF"):
    463             if line.startswith(TYPE_HEADER) and content_type is None:
    464                 content_type = line.split()[1]
    465             elif line.startswith(ENCODING_HEADER) and encoding is None:
    466                 encoding = line.split()[1]
    467             elif line.startswith(LENGTH_HEADER) and content_length is None:
    468                 timeout = deadline - time.time()
    469                 content_length = int(line[len(LENGTH_HEADER):])
    470                 # FIXME: Technically there should probably be another blank
    471                 # line here, but DRT doesn't write one.
    472                 content = self._server_process.read(timeout, content_length)
    473             elif line.startswith(HASH_HEADER):
    474                 content_hash = line.split()[1]
    475             else:
    476                 content += line
    477             line = self._server_process.read_line(timeout)
    478             timeout = deadline - time.time()
    479         return ContentBlock(content_type, encoding, content_hash, content)
    480 
    481     def stop(self):
    482         if self._server_process:
    483             self._server_process.stop()
    484             self._server_process = None
    485 
    486 
    487 class ContentBlock(object):
    488     def __init__(self, content_type, encoding, content_hash, content):
    489         self.content_type = content_type
    490         self.encoding = encoding
    491         self.content_hash = content_hash
    492         self.content = content
    493         if self.encoding == 'base64':
    494             self.decoded_content = base64.b64decode(content)
    495         else:
    496             self.decoded_content = content
    497