1#!/usr/bin/env python
2# Copyright (C) 2010 Google Inc. All rights reserved.
3# Copyright (C) 2010 Gabor Rapcsanyi <rgabor@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
33import base64
34import logging
35import operator
36import os
37import re
38import signal
39import sys
40import time
41import webbrowser
42
43from webkitpy.common.system import ospath
44from webkitpy.layout_tests.port import base
45from webkitpy.layout_tests.port import server_process
46
47_log = logging.getLogger("webkitpy.layout_tests.port.webkit")
48
49
50class 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
362class 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
487class 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