• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright (C) 2010 Google Inc. All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8#     * Redistributions of source code must retain the above copyright
9# notice, this list of conditions and the following disclaimer.
10#     * Redistributions in binary form must reproduce the above
11# copyright notice, this list of conditions and the following disclaimer
12# in the documentation and/or other materials provided with the
13# distribution.
14#     * Neither the Google name nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
30"""Abstract base class of Port-specific entrypoints for the layout tests
31test infrastructure (the Port and Driver classes)."""
32
33import cgi
34import difflib
35import errno
36import os
37import subprocess
38import sys
39
40import apache_http_server
41import http_server
42import websocket_server
43
44# Python bug workaround.  See Port.wdiff_text() for an explanation.
45_wdiff_available = True
46
47
48# FIXME: This class should merge with webkitpy.webkit_port at some point.
49class Port(object):
50    """Abstract class for Port-specific hooks for the layout_test package.
51    """
52
53    def __init__(self, port_name=None, options=None):
54        self._name = port_name
55        self._options = options
56        self._helper = None
57        self._http_server = None
58        self._webkit_base_dir = None
59        self._websocket_server = None
60
61    def baseline_path(self):
62        """Return the absolute path to the directory to store new baselines
63        in for this port."""
64        raise NotImplementedError('Port.baseline_path')
65
66    def baseline_search_path(self):
67        """Return a list of absolute paths to directories to search under for
68        baselines. The directories are searched in order."""
69        raise NotImplementedError('Port.baseline_search_path')
70
71    def check_sys_deps(self):
72        """If the port needs to do some runtime checks to ensure that the
73        tests can be run successfully, they should be done here.
74
75        Returns whether the system is properly configured."""
76        raise NotImplementedError('Port.check_sys_deps')
77
78    def compare_text(self, actual_text, expected_text):
79        """Return whether or not the two strings are *not* equal. This
80        routine is used to diff text output.
81
82        While this is a generic routine, we include it in the Port
83        interface so that it can be overriden for testing purposes."""
84        return actual_text != expected_text
85
86    def diff_image(self, actual_filename, expected_filename, diff_filename):
87        """Compare two image files and produce a delta image file.
88
89        Return 1 if the two files are different, 0 if they are the same.
90        Also produce a delta image of the two images and write that into
91        |diff_filename|.
92
93        While this is a generic routine, we include it in the Port
94        interface so that it can be overriden for testing purposes."""
95        executable = self._path_to_image_diff()
96        cmd = [executable, '--diff', actual_filename, expected_filename,
97               diff_filename]
98        result = 1
99        try:
100            result = subprocess.call(cmd)
101        except OSError, e:
102            if e.errno == errno.ENOENT or e.errno == errno.EACCES:
103                _compare_available = False
104            else:
105                raise e
106        except ValueError:
107            # work around a race condition in Python 2.4's implementation
108            # of subprocess.Popen. See http://bugs.python.org/issue1199282 .
109            pass
110        return result
111
112    def diff_text(self, actual_text, expected_text,
113                  actual_filename, expected_filename):
114        """Returns a string containing the diff of the two text strings
115        in 'unified diff' format.
116
117        While this is a generic routine, we include it in the Port
118        interface so that it can be overriden for testing purposes."""
119        diff = difflib.unified_diff(expected_text.splitlines(True),
120                                    actual_text.splitlines(True),
121                                    expected_filename,
122                                    actual_filename)
123        return ''.join(diff)
124
125    def expected_baselines(self, filename, suffix, all_baselines=False):
126        """Given a test name, finds where the baseline results are located.
127
128        Args:
129        filename: absolute filename to test file
130        suffix: file suffix of the expected results, including dot; e.g.
131            '.txt' or '.png'.  This should not be None, but may be an empty
132            string.
133        all_baselines: If True, return an ordered list of all baseline paths
134            for the given platform. If False, return only the first one.
135        Returns
136        a list of ( platform_dir, results_filename ), where
137            platform_dir - abs path to the top of the results tree (or test
138                tree)
139            results_filename - relative path from top of tree to the results
140                file
141            (os.path.join of the two gives you the full path to the file,
142                unless None was returned.)
143        Return values will be in the format appropriate for the current
144        platform (e.g., "\\" for path separators on Windows). If the results
145        file is not found, then None will be returned for the directory,
146        but the expected relative pathname will still be returned.
147
148        This routine is generic but lives here since it is used in
149        conjunction with the other baseline and filename routines that are
150        platform specific.
151        """
152        testname = os.path.splitext(self.relative_test_filename(filename))[0]
153
154        baseline_filename = testname + '-expected' + suffix
155
156        baseline_search_path = self.baseline_search_path()
157
158        baselines = []
159        for platform_dir in baseline_search_path:
160            if os.path.exists(os.path.join(platform_dir, baseline_filename)):
161                baselines.append((platform_dir, baseline_filename))
162
163            if not all_baselines and baselines:
164                return baselines
165
166        # If it wasn't found in a platform directory, return the expected
167        # result in the test directory, even if no such file actually exists.
168        platform_dir = self.layout_tests_dir()
169        if os.path.exists(os.path.join(platform_dir, baseline_filename)):
170            baselines.append((platform_dir, baseline_filename))
171
172        if baselines:
173            return baselines
174
175        return [(None, baseline_filename)]
176
177    def expected_filename(self, filename, suffix):
178        """Given a test name, returns an absolute path to its expected results.
179
180        If no expected results are found in any of the searched directories,
181        the directory in which the test itself is located will be returned.
182        The return value is in the format appropriate for the platform
183        (e.g., "\\" for path separators on windows).
184
185        Args:
186        filename: absolute filename to test file
187        suffix: file suffix of the expected results, including dot; e.g. '.txt'
188            or '.png'.  This should not be None, but may be an empty string.
189        platform: the most-specific directory name to use to build the
190            search list of directories, e.g., 'chromium-win', or
191            'chromium-mac-leopard' (we follow the WebKit format)
192
193        This routine is generic but is implemented here to live alongside
194        the other baseline and filename manipulation routines.
195        """
196        platform_dir, baseline_filename = self.expected_baselines(
197            filename, suffix)[0]
198        if platform_dir:
199            return os.path.join(platform_dir, baseline_filename)
200        return os.path.join(self.layout_tests_dir(), baseline_filename)
201
202    def filename_to_uri(self, filename):
203        """Convert a test file to a URI."""
204        LAYOUTTEST_HTTP_DIR = "http/tests/"
205        LAYOUTTEST_WEBSOCKET_DIR = "websocket/tests/"
206
207        relative_path = self.relative_test_filename(filename)
208        port = None
209        use_ssl = False
210
211        if relative_path.startswith(LAYOUTTEST_HTTP_DIR):
212            # http/tests/ run off port 8000 and ssl/ off 8443
213            relative_path = relative_path[len(LAYOUTTEST_HTTP_DIR):]
214            port = 8000
215        elif relative_path.startswith(LAYOUTTEST_WEBSOCKET_DIR):
216            # websocket/tests/ run off port 8880 and 9323
217            # Note: the root is /, not websocket/tests/
218            port = 8880
219
220        # Make http/tests/local run as local files. This is to mimic the
221        # logic in run-webkit-tests.
222        #
223        # TODO(dpranke): remove the media reference and the SSL reference?
224        if (port and not relative_path.startswith("local/") and
225            not relative_path.startswith("media/")):
226            if relative_path.startswith("ssl/"):
227                port += 443
228                protocol = "https"
229            else:
230                protocol = "http"
231            return "%s://127.0.0.1:%u/%s" % (protocol, port, relative_path)
232
233        if sys.platform in ('cygwin', 'win32'):
234            return "file:///" + self.get_absolute_path(filename)
235        return "file://" + self.get_absolute_path(filename)
236
237    def get_absolute_path(self, filename):
238        """Return the absolute path in unix format for the given filename.
239
240        This routine exists so that platforms that don't use unix filenames
241        can convert accordingly."""
242        return os.path.abspath(filename)
243
244    def layout_tests_dir(self):
245        """Return the absolute path to the top of the LayoutTests directory."""
246        return self.path_from_webkit_base('LayoutTests')
247
248    def maybe_make_directory(self, *path):
249        """Creates the specified directory if it doesn't already exist."""
250        try:
251            os.makedirs(os.path.join(*path))
252        except OSError, e:
253            if e.errno != errno.EEXIST:
254                raise
255
256    def name(self):
257        """Return the name of the port (e.g., 'mac', 'chromium-win-xp').
258
259        Note that this is different from the test_platform_name(), which
260        may be different (e.g., 'win-xp' instead of 'chromium-win-xp'."""
261        return self._name
262
263    def num_cores(self):
264        """Return the number of cores/cpus available on this machine.
265
266        This routine is used to determine the default amount of parallelism
267        used by run-chromium-webkit-tests."""
268        raise NotImplementedError('Port.num_cores')
269
270    def path_from_webkit_base(self, *comps):
271        """Returns the full path to path made by joining the top of the
272        WebKit source tree and the list of path components in |*comps|."""
273        if not self._webkit_base_dir:
274            abspath = os.path.abspath(__file__)
275            self._webkit_base_dir = abspath[0:abspath.find('WebKitTools')]
276        return os.path.join(self._webkit_base_dir, *comps)
277
278    def remove_directory(self, *path):
279        """Recursively removes a directory, even if it's marked read-only.
280
281        Remove the directory located at *path, if it exists.
282
283        shutil.rmtree() doesn't work on Windows if any of the files
284        or directories are read-only, which svn repositories and
285        some .svn files are.  We need to be able to force the files
286        to be writable (i.e., deletable) as we traverse the tree.
287
288        Even with all this, Windows still sometimes fails to delete a file,
289        citing a permission error (maybe something to do with antivirus
290        scans or disk indexing).  The best suggestion any of the user
291        forums had was to wait a bit and try again, so we do that too.
292        It's hand-waving, but sometimes it works. :/
293        """
294        file_path = os.path.join(*path)
295        if not os.path.exists(file_path):
296            return
297
298        win32 = False
299        if sys.platform == 'win32':
300            win32 = True
301            # Some people don't have the APIs installed. In that case we'll do
302            # without.
303            try:
304                win32api = __import__('win32api')
305                win32con = __import__('win32con')
306            except ImportError:
307                win32 = False
308
309            def remove_with_retry(rmfunc, path):
310                os.chmod(path, stat.S_IWRITE)
311                if win32:
312                    win32api.SetFileAttributes(path,
313                                              win32con.FILE_ATTRIBUTE_NORMAL)
314                try:
315                    return rmfunc(path)
316                except EnvironmentError, e:
317                    if e.errno != errno.EACCES:
318                        raise
319                    print 'Failed to delete %s: trying again' % repr(path)
320                    time.sleep(0.1)
321                    return rmfunc(path)
322        else:
323
324            def remove_with_retry(rmfunc, path):
325                if os.path.islink(path):
326                    return os.remove(path)
327                else:
328                    return rmfunc(path)
329
330        for root, dirs, files in os.walk(file_path, topdown=False):
331            # For POSIX:  making the directory writable guarantees
332            # removability. Windows will ignore the non-read-only
333            # bits in the chmod value.
334            os.chmod(root, 0770)
335            for name in files:
336                remove_with_retry(os.remove, os.path.join(root, name))
337            for name in dirs:
338                remove_with_retry(os.rmdir, os.path.join(root, name))
339
340        remove_with_retry(os.rmdir, file_path)
341
342    def test_platform_name(self):
343        return self._name
344
345    def relative_test_filename(self, filename):
346        """Relative unix-style path for a filename under the LayoutTests
347        directory. Filenames outside the LayoutTests directory should raise
348        an error."""
349        return filename[len(self.layout_tests_dir()) + 1:]
350
351    def results_directory(self):
352        """Absolute path to the place to store the test results."""
353        raise NotImplemented('Port.results_directory')
354
355    def setup_test_run(self):
356        """This routine can be overridden to perform any port-specific
357        work that shouuld be done at the beginning of a test run."""
358        pass
359
360    def show_html_results_file(self, results_filename):
361        """This routine should display the HTML file pointed at by
362        results_filename in a users' browser."""
363        raise NotImplementedError('Port.show_html_results_file')
364
365    def start_driver(self, png_path, options):
366        """Starts a new test Driver and returns a handle to the object."""
367        raise NotImplementedError('Port.start_driver')
368
369    def start_helper(self):
370        """Start a layout test helper if needed on this port. The test helper
371        is used to reconfigure graphics settings and do other things that
372        may be necessary to ensure a known test configuration."""
373        raise NotImplementedError('Port.start_helper')
374
375    def start_http_server(self):
376        """Start a web server if it is available. Do nothing if
377        it isn't. This routine is allowed to (and may) fail if a server
378        is already running."""
379        if self._options.use_apache:
380            self._http_server = apache_http_server.LayoutTestApacheHttpd(self,
381                self._options.results_directory)
382        else:
383            self._http_server = http_server.Lighttpd(self,
384                self._options.results_directory)
385        self._http_server.start()
386
387    def start_websocket_server(self):
388        """Start a websocket server if it is available. Do nothing if
389        it isn't. This routine is allowed to (and may) fail if a server
390        is already running."""
391        self._websocket_server = websocket_server.PyWebSocket(self,
392            self._options.results_directory)
393        self._websocket_server.start()
394
395    def stop_helper(self):
396        """Shut down the test helper if it is running. Do nothing if
397        it isn't, or it isn't available."""
398        raise NotImplementedError('Port.stop_helper')
399
400    def stop_http_server(self):
401        """Shut down the http server if it is running. Do nothing if
402        it isn't, or it isn't available."""
403        if self._http_server:
404            self._http_server.stop()
405
406    def stop_websocket_server(self):
407        """Shut down the websocket server if it is running. Do nothing if
408        it isn't, or it isn't available."""
409        if self._websocket_server:
410            self._websocket_server.stop()
411
412    def test_expectations(self):
413        """Returns the test expectations for this port.
414
415        Basically this string should contain the equivalent of a
416        test_expectations file. See test_expectations.py for more details."""
417        raise NotImplementedError('Port.test_expectations')
418
419    def test_base_platform_names(self):
420        """Return a list of the 'base' platforms on your port. The base
421        platforms represent different architectures, operating systems,
422        or implementations (as opposed to different versions of a single
423        platform). For example, 'mac' and 'win' might be different base
424        platforms, wherease 'mac-tiger' and 'mac-leopard' might be
425        different platforms. This routine is used by the rebaselining tool
426        and the dashboards, and the strings correspond to the identifiers
427        in your test expectations (*not* necessarily the platform names
428        themselves)."""
429        raise NotImplementedError('Port.base_test_platforms')
430
431    def test_platform_name(self):
432        """Returns the string that corresponds to the given platform name
433        in the test expectations. This may be the same as name(), or it
434        may be different. For example, chromium returns 'mac' for
435        'chromium-mac'."""
436        raise NotImplementedError('Port.test_platform_name')
437
438    def test_platforms(self):
439        """Returns the list of test platform identifiers as used in the
440        test_expectations and on dashboards, the rebaselining tool, etc.
441
442        Note that this is not necessarily the same as the list of ports,
443        which must be globally unique (e.g., both 'chromium-mac' and 'mac'
444        might return 'mac' as a test_platform name'."""
445        raise NotImplementedError('Port.platforms')
446
447    def version(self):
448        """Returns a string indicating the version of a given platform, e.g.
449        '-leopard' or '-xp'.
450
451        This is used to help identify the exact port when parsing test
452        expectations, determining search paths, and logging information."""
453        raise NotImplementedError('Port.version')
454
455    def wdiff_text(self, actual_filename, expected_filename):
456        """Returns a string of HTML indicating the word-level diff of the
457        contents of the two filenames. Returns an empty string if word-level
458        diffing isn't available."""
459        executable = self._path_to_wdiff()
460        cmd = [executable,
461               '--start-delete=##WDIFF_DEL##',
462               '--end-delete=##WDIFF_END##',
463               '--start-insert=##WDIFF_ADD##',
464               '--end-insert=##WDIFF_END##',
465               expected_filename,
466               actual_filename]
467        global _wdiff_available
468        result = ''
469        try:
470            # Python's Popen has a bug that causes any pipes opened to a
471            # process that can't be executed to be leaked.  Since this
472            # code is specifically designed to tolerate exec failures
473            # to gracefully handle cases where wdiff is not installed,
474            # the bug results in a massive file descriptor leak. As a
475            # workaround, if an exec failure is ever experienced for
476            # wdiff, assume it's not available.  This will leak one
477            # file descriptor but that's better than leaking each time
478            # wdiff would be run.
479            #
480            # http://mail.python.org/pipermail/python-list/
481            #    2008-August/505753.html
482            # http://bugs.python.org/issue3210
483            #
484            # It also has a threading bug, so we don't output wdiff if
485            # the Popen raises a ValueError.
486            # http://bugs.python.org/issue1236
487            if _wdiff_available:
488                try:
489                    wdiff = subprocess.Popen(cmd,
490                        stdout=subprocess.PIPE).communicate()[0]
491                except ValueError, e:
492                    # Working around a race in Python 2.4's implementation
493                    # of Popen().
494                    wdiff = ''
495                wdiff = cgi.escape(wdiff)
496                wdiff = wdiff.replace('##WDIFF_DEL##', '<span class=del>')
497                wdiff = wdiff.replace('##WDIFF_ADD##', '<span class=add>')
498                wdiff = wdiff.replace('##WDIFF_END##', '</span>')
499                result = '<head><style>.del { background: #faa; } '
500                result += '.add { background: #afa; }</style></head>'
501                result += '<pre>' + wdiff + '</pre>'
502        except OSError, e:
503            if (e.errno == errno.ENOENT or e.errno == errno.EACCES or
504                e.errno == errno.ECHILD):
505                _wdiff_available = False
506            else:
507                raise e
508        return result
509
510    #
511    # PROTECTED ROUTINES
512    #
513    # The routines below should only be called by routines in this class
514    # or any of its subclasses.
515    #
516
517    def _kill_process(self, pid):
518        """Forcefully kill a process.
519
520        This routine should not be used or needed generically, but can be
521        used in helper files like http_server.py."""
522        raise NotImplementedError('Port.kill_process')
523
524    def _path_to_apache(self):
525        """Returns the full path to the apache binary.
526
527        This is needed only by ports that use the apache_http_server module."""
528        raise NotImplementedError('Port.path_to_apache')
529
530    def _path_to_apache_config_file(self):
531        """Returns the full path to the apache binary.
532
533        This is needed only by ports that use the apache_http_server module."""
534        raise NotImplementedError('Port.path_to_apache_config_file')
535
536    def _path_to_driver(self):
537        """Returns the full path to the test driver (DumpRenderTree)."""
538        raise NotImplementedError('Port.path_to_driver')
539
540    def _path_to_helper(self):
541        """Returns the full path to the layout_test_helper binary, which
542        is used to help configure the system for the test run, or None
543        if no helper is needed.
544
545        This is likely only used by start/stop_helper()."""
546        raise NotImplementedError('Port._path_to_helper')
547
548    def _path_to_image_diff(self):
549        """Returns the full path to the image_diff binary, or None if it
550        is not available.
551
552        This is likely used only by diff_image()"""
553        raise NotImplementedError('Port.path_to_image_diff')
554
555    def _path_to_lighttpd(self):
556        """Returns the path to the LigHTTPd binary.
557
558        This is needed only by ports that use the http_server.py module."""
559        raise NotImplementedError('Port._path_to_lighttpd')
560
561    def _path_to_lighttpd_modules(self):
562        """Returns the path to the LigHTTPd modules directory.
563
564        This is needed only by ports that use the http_server.py module."""
565        raise NotImplementedError('Port._path_to_lighttpd_modules')
566
567    def _path_to_lighttpd_php(self):
568        """Returns the path to the LigHTTPd PHP executable.
569
570        This is needed only by ports that use the http_server.py module."""
571        raise NotImplementedError('Port._path_to_lighttpd_php')
572
573    def _path_to_wdiff(self):
574        """Returns the full path to the wdiff binary, or None if it is
575        not available.
576
577        This is likely used only by wdiff_text()"""
578        raise NotImplementedError('Port._path_to_wdiff')
579
580    def _shut_down_http_server(self, pid):
581        """Forcefully and synchronously kills the web server.
582
583        This routine should only be called from http_server.py or its
584        subclasses."""
585        raise NotImplementedError('Port._shut_down_http_server')
586
587    def _webkit_baseline_path(self, platform):
588        """Return the  full path to the top of the baseline tree for a
589        given platform."""
590        return os.path.join(self.layout_tests_dir(), 'platform',
591                            platform)
592
593
594class Driver:
595    """Abstract interface for the DumpRenderTree interface."""
596
597    def __init__(self, port, png_path, options):
598        """Initialize a Driver to subsequently run tests.
599
600        Typically this routine will spawn DumpRenderTree in a config
601        ready for subsequent input.
602
603        port - reference back to the port object.
604        png_path - an absolute path for the driver to write any image
605            data for a test (as a PNG). If no path is provided, that
606            indicates that pixel test results will not be checked.
607        options - any port-specific driver options."""
608        raise NotImplementedError('Driver.__init__')
609
610    def run_test(self, uri, timeout, checksum):
611        """Run a single test and return the results.
612
613        Note that it is okay if a test times out or crashes and leaves
614        the driver in an indeterminate state. The upper layers of the program
615        are responsible for cleaning up and ensuring things are okay.
616
617        uri - a full URI for the given test
618        timeout - number of milliseconds to wait before aborting this test.
619        checksum - if present, the expected checksum for the image for this
620            test
621
622        Returns a tuple of the following:
623            crash - a boolean indicating whether the driver crashed on the test
624            timeout - a boolean indicating whehter the test timed out
625            checksum - a string containing the checksum of the image, if
626                present
627            output - any text output
628            error - any unexpected or additional (or error) text output
629
630        Note that the image itself should be written to the path that was
631        specified in the __init__() call."""
632        raise NotImplementedError('Driver.run_test')
633
634    def poll(self):
635        """Returns None if the Driver is still running. Returns the returncode
636        if it has exited."""
637        raise NotImplementedError('Driver.poll')
638
639    def returncode(self):
640        """Returns the system-specific returncode if the Driver has stopped or
641        exited."""
642        raise NotImplementedError('Driver.returncode')
643
644    def stop(self):
645        raise NotImplementedError('Driver.stop')
646