chromium/third_party/blink/web_tests/PRESUBMIT.py

# Copyright 2013 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""web_tests/ presubmit script for Blink.

See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
for more details about the presubmit API built into gcl.
"""

import filecmp
import inspect
import os
import sys
import tempfile
import re
from html.parser import HTMLParser
from typing import List


def _CheckTestharnessWdspecResults(input_api, output_api):
    """Checks for all-PASS generic baselines for testharness/wdspec tests.

    These files are unnecessary because for testharness/wdspec tests, if there is no
    baseline file then the test is considered to pass when the output is all
    PASS. Note that only generic baselines are checked because platform specific
    and virtual baselines might be needed to prevent fallback.
    """
    baseline_files = _TxtGenericBaselinesToCheck(input_api)
    if not baseline_files:
        return []

    checker_path = input_api.os_path.join(input_api.PresubmitLocalPath(), '..',
                                          'tools', 'check_expected_pass.py')

    # When running git cl presubmit --all this presubmit may be asked to check
    # ~19,000 files. Passing these on the command line would far exceed Windows
    # limits, so we use --path-files instead.

    # We have to set delete=False and then let the object go out of scope so
    # that the file can be opened by name on Windows.
    with tempfile.NamedTemporaryFile('w+', newline='', delete=False) as f:
        for path in baseline_files:
            f.write('%s\n' % path)
        paths_name = f.name

    args = [
        input_api.python3_executable, checker_path, '--path-files', paths_name
    ]
    _, errs = input_api.subprocess.Popen(
        args,
        stdout=input_api.subprocess.PIPE,
        stderr=input_api.subprocess.PIPE,
        universal_newlines=True).communicate()

    os.remove(paths_name)
    if errs:
        return [output_api.PresubmitError(errs)]
    return []


def _TxtGenericBaselinesToCheck(input_api):
    """Returns a list of paths of generic baselines for testharness/wdspec tests."""
    baseline_files = []
    this_dir = input_api.PresubmitLocalPath()
    for f in input_api.AffectedFiles():
        if f.Action() == 'D':
            continue
        path = f.AbsoluteLocalPath()
        if not path.endswith('-expected.txt'):
            continue
        if (input_api.os_path.join(this_dir, 'platform') in path
                or input_api.os_path.join(this_dir, 'virtual') in path
                or input_api.os_path.join(this_dir, 'flag-specific') in path):
            continue
        baseline_files.append(path)
    return baseline_files


def _CheckFilesUsingEventSender(input_api, output_api):
    """Check if any new layout tests still use eventSender. If they do, we encourage replacing them with
       chrome.gpuBenchmarking.pointerActionSequence.
    """
    results = []
    actions = ["eventSender.touch", "eventSender.mouse", "eventSender.gesture"]
    for f in input_api.AffectedFiles():
        if f.Action() == 'A':
            for line_num, line in f.ChangedContents():
                if line.find("eventSender.beginDragWithFiles") != -1:
                    break
                if any(action in line for action in actions):
                    results.append(output_api.PresubmitPromptWarning(
                        'eventSender is deprecated, please use chrome.gpuBenchmarking.pointerActionSequence instead ' +
                        '(see https://crbug.com/711340 and http://goo.gl/BND75q).\n' +
                        'Files: %s:%d %s ' % (f.LocalPath(), line_num, line)))
    return results


def _CheckTestExpectations(input_api, output_api):
    results = []
    os_path = input_api.os_path
    sys.path.append(
        os_path.join(
            os_path.dirname(
                os_path.abspath(inspect.getfile(_CheckTestExpectations))),
                '..', 'tools'))
    from blinkpy.presubmit.lint_test_expectations import (
        PresubmitCheckTestExpectations)
    results.extend(PresubmitCheckTestExpectations(input_api, output_api))
    return results


def _CheckForRedundantBaselines(input_api, output_api, max_tests: int = 1000):
    tests = _TestsCorrespondingToAffectedBaselines(input_api, max_tests)
    if not tests:
        return []
    elif len(tests) > max_tests:
        return [
            output_api.PresubmitNotifyResult(
                'Too many tests to check for redundant baselines; skipping.',
                items=tests),
        ]
    path_to_blink_tool = input_api.os_path.join(input_api.PresubmitLocalPath(),
                                                input_api.os_path.pardir,
                                                'tools', 'blink_tool.py')
    with input_api.CreateTemporaryFile(mode='w+') as test_name_file:
        for test in tests:
            test_name_file.write(f'{test}\n')
        test_name_file.flush()
        command_args = [
            input_api.python3_executable,
            path_to_blink_tool,
            'optimize-baselines',
            '--no-manifest-update',
            '--check',
            f'--test-name-file={test_name_file.name}',
        ]
        command = input_api.Command(
            name='Checking for redundant affected baselines ...',
            cmd=command_args,
            kwargs={},
            message=output_api.PresubmitPromptWarning,
            python3=True)
        return input_api.RunTests([command])


def _TestsCorrespondingToAffectedBaselines(input_api,
                                           max_tests: int = 1000) -> List[str]:
    sep = input_api.re.escape(input_api.os_path.sep)
    baseline_pattern = input_api.re.compile(
        r'((platform|flag-specific)%s[^%s]+%s)?(virtual%s[^%s]+%s)?'
        r'(?P<test_prefix>.*)-expected\.(txt|png|wav)' % ((sep, ) * 6))
    test_paths = set()
    for affected_file in input_api.AffectedFiles():
        if len(test_paths) > max_tests:
            # Exit early; no need to glob for more tests.
            break
        baseline_path_from_web_tests = input_api.os_path.relpath(
            affected_file.AbsoluteLocalPath(), input_api.PresubmitLocalPath())
        baseline_match = baseline_pattern.fullmatch(
            baseline_path_from_web_tests)
        if not baseline_match:
            continue
        # Baselines for WPT-style variants have sanitized filenames with '?' and
        # '&' in the query parameter section converted to '_', like:
        #   a/b.html?c&d -> a/b_c_d-expected.txt
        #
        # Regrettably, this is a lossy coercion that cannot be distinguished
        # from tests with '_' in the original test ID, like:
        #   a/b_c_d.html -> a/b_c_d-expected.txt
        #
        # Here, we err on the side of not attempting to check such tests, which
        # could be unrelated.
        test_prefix = baseline_match['test_prefix']
        # Getting the test name from the baseline path is not as easy as the
        # other direction. Try all extensions as a heuristic instead.
        for extension in [
                'html', 'xml', 'xhtml', 'xht', 'pl', 'htm', 'php', 'svg',
                'mht', 'pdf', 'js'
        ]:
            abs_prefix = input_api.os_path.join(input_api.PresubmitLocalPath(),
                                                test_prefix)
            test_paths.update(input_api.glob(f'{abs_prefix}*.{extension}'))
    return [
        input_api.os_path.relpath(test_path, input_api.PresubmitLocalPath())
        for test_path in sorted(test_paths)
    ]


def _CheckForJSTest(input_api, output_api):
    """'js-test.js' is the past, 'testharness.js' is our glorious future"""
    jstest_re = input_api.re.compile(r'resources/js-test.js')

    def source_file_filter(path):
        return input_api.FilterSourceFile(path, files_to_check=[r'\.(html|js|php|pl|svg)$'])

    errors = input_api.canned_checks._FindNewViolationsOfRule(
        lambda _, x: not jstest_re.search(x), input_api, source_file_filter)
    errors = ['  * %s' % violation for violation in errors]
    if errors:
        return [output_api.PresubmitPromptOrNotify(
            '"resources/js-test.js" is deprecated; please write new layout '
            'tests using the assertions in "resources/testharness.js" '
            'instead, as these can be more easily upstreamed to Web Platform '
            'Tests for cross-vendor compatibility testing. If you\'re not '
            'already familiar with this framework, a tutorial is available at '
            'https://darobin.github.io/test-harness-tutorial/docs/using-testharness.html'
            '\n\n%s' % '\n'.join(errors))]
    return []


def _CheckForInvalidPreferenceError(input_api, output_api):
    pattern = input_api.re.compile('Invalid name for preference: (.+)')
    results = []

    for f in input_api.AffectedFiles():
        if not f.LocalPath().endswith('-expected.txt'):
            continue
        for line_num, line in f.ChangedContents():
            error = pattern.search(line)
            if error:
                results.append(output_api.PresubmitError('Found an invalid preference %s in expected result %s:%s' % (error.group(1), f, line_num)))
    return results


def _CheckRunAfterLayoutAndPaintJS(input_api, output_api):
    """Checks if resources/run-after-layout-and-paint.js and
       http/tests/resources/run-after-layout-and-paint.js are the same."""
    js_file = input_api.os_path.join(input_api.PresubmitLocalPath(),
        'resources', 'run-after-layout-and-paint.js')
    http_tests_js_file = input_api.os_path.join(input_api.PresubmitLocalPath(),
        'http', 'tests', 'resources', 'run-after-layout-and-paint.js')
    for f in input_api.AffectedFiles():
        path = f.AbsoluteLocalPath()
        if path == js_file or path == http_tests_js_file:
            if not filecmp.cmp(js_file, http_tests_js_file):
                return [output_api.PresubmitError(
                    '%s and %s must be kept exactly the same' %
                    (js_file, http_tests_js_file))]
            break
    return []


def _CheckForUnlistedTestFolder(input_api, output_api):
    """Checks all the test folders under web_tests are listed in BUILD.gn.
    """
    this_dir = input_api.PresubmitLocalPath()
    possible_new_dirs = set()
    for f in input_api.AffectedFiles():
        if f.Action() == 'A':
            # We only check added folders. For deleted folders, if BUILD.gn is
            # not updated, the build will fail at upload step. The reason is that
            # we can not know if the folder is deleted as there can be local
            # unchecked in files.
            path = f.AbsoluteLocalPath()
            fns = path[len(this_dir)+1:].split('/')
            if len(fns) > 1:
                possible_new_dirs.add(fns[0])

    if possible_new_dirs:
        path_build_gn = input_api.os_path.join(input_api.change.RepositoryRoot(), 'BUILD.gn')
        dirs_from_build_gn = []
        start_line = '# === List Test Cases folders here ==='
        end_line = '# === Test Case Folders Ends ==='
        end_line_count = 0
        find_start_line  = False
        for line in input_api.ReadFile(path_build_gn).splitlines():
            line = line.strip()
            if line.startswith(start_line):
                find_start_line = True
                continue
            if find_start_line:
                if line.startswith(end_line):
                    find_start_line = False
                    end_line_count += 1
                    if end_line_count == 2:
                        break
                    continue
                if len(line.split('/')) > 1:
                    dirs_from_build_gn.append(line.split('/')[-2])
        dirs_from_build_gn.extend(
            ['platform', 'FlagExpectations', 'flag-specific', 'TestLists'])

        new_dirs = [x for x in possible_new_dirs if x not in dirs_from_build_gn]
        if new_dirs:
            dir_plural = "directories" if len(new_dirs) > 1 else "directory"
            error_message = (
                'This CL adds new %s(%s) under //third_party/blink/web_tests/, but //BUILD.gn '
                'is not updated. Please add the %s to BUILD.gn.' % (dir_plural, ', '.join(new_dirs), dir_plural))
            if input_api.is_committing:
                return [output_api.PresubmitError(error_message)]
            else:
                return [output_api.PresubmitPromptWarning(error_message)]
    return []


def _CheckForExtraVirtualBaselines(input_api, output_api):
    """Checks that expectations in virtual test suites are for virtual test suites that exist
    """
    # This test fails on Windows because win32pipe is not available and
    # other errors.
    if os.name == 'nt':
        return []

    os_path = input_api.os_path

    local_dir = os_path.relpath(
        os_path.normpath('{0}/'.format(input_api.PresubmitLocalPath().replace(
            os_path.sep, '/'))), input_api.change.RepositoryRoot())

    check_all = False
    check_files = []
    for f in input_api.AffectedFiles(include_deletes=False):
        local_path = f.LocalPath()
        assert local_path.startswith(local_dir)
        local_path = os_path.relpath(local_path, local_dir)
        path_components = local_path.split(os_path.sep)
        if f.Action() == 'A':
            if len(path_components) > 2 and path_components[0] == 'virtual':
                check_files.append((local_path, path_components[1]))
            elif (len(path_components) > 4 and path_components[2] == 'virtual'
                  and (path_components[0] == 'platform'
                       or path_components[0] == 'flag-specific')):
                check_files.append((local_path, path_components[3]))
        elif local_path == 'VirtualTestSuites':
            check_all = True

    if not check_all and len(check_files) == 0:
        return []

    from blinkpy.common.host import Host
    port_factory = Host().port_factory
    known_virtual_suites = [
        suite.full_prefix[8:-1] for suite in port_factory.get(
            port_factory.all_port_names()[0]).virtual_test_suites()
    ]

    results = []
    if check_all:
        for f in input_api.change.AllFiles(
                os_path.join(input_api.PresubmitLocalPath(), "virtual")):
            suite = f.split('/')[0]
            if not suite in known_virtual_suites:
                path = os_path.relpath(
                    os_path.join(input_api.PresubmitLocalPath(), "virtual", f),
                    input_api.change.RepositoryRoot())
                results.append(
                    output_api.PresubmitError(
                        "Baseline %s exists, but %s is not a known virtual test suite."
                        % (path, suite)))
        for subdir in ["platform", "flag-specific"]:
            for f in input_api.change.AllFiles(
                    os_path.join(input_api.PresubmitLocalPath(), subdir)):
                path_components = f.split('/')
                if len(path_components) < 3 or path_components[1] != 'virtual':
                    continue
                suite = path_components[2]
                if not suite in known_virtual_suites:
                    path = os_path.relpath(
                        os_path.join(input_api.PresubmitLocalPath(), subdir,
                                     f), input_api.change.RepositoryRoot())
                    results.append(
                        output_api.PresubmitError(
                            "Baseline %s exists, but %s is not a known virtual test suite."
                            % (path, suite)))
    else:
        for (f, suite) in check_files:
            if not suite in known_virtual_suites:
                path = os_path.relpath(
                    os_path.join(input_api.PresubmitLocalPath(), f),
                    input_api.change.RepositoryRoot())
                results.append(
                    output_api.PresubmitError(
                        "This CL adds a new baseline %s, but %s is not a known virtual test suite."
                        % (path, suite)))
    return results


def _CheckWebViewExpectations(input_api, output_api):
    src_dir = os.path.join(input_api.PresubmitLocalPath(), os.pardir,
                           os.pardir, os.pardir)
    webview_data_dir = input_api.os_path.join(src_dir, 'android_webview',
                                              'tools', 'system_webview_shell',
                                              'test', 'data', 'webexposed')
    if webview_data_dir not in sys.path:
        sys.path.append(webview_data_dir)

    # pylint: disable=import-outside-toplevel
    from exposed_webview_interfaces_presubmit import (
        CheckNotWebViewExposedInterfaces)
    return CheckNotWebViewExposedInterfaces(input_api, output_api)


class _DoctypeParser(HTMLParser):
    """Parses HTML to check if there exists a DOCTYPE declaration before all other tags.
    """

    def __init__(self):
        super().__init__()
        self.encountered_tag = False
        self.doctype = ""

    def handle_starttag(self, *_):
        self.encountered_tag = True

    def handle_startendtag(self, *_):
        self.encountered_tag = True

    def handle_decl(self, decl):
        if not self.encountered_tag:
            self.doctype = decl
            self.encountered_tag = True


def _IsDoctypeHTMLSet(lines):
    """Returns true if the given HTML file starts with <!DOCTYPE html>.
    """
    parser = _DoctypeParser()
    for l in lines:
        parser.feed(l)

    return re.match("DOCTYPE\s*html\s*$", parser.doctype, re.IGNORECASE)


def _CheckForDoctypeHTML(input_api, output_api):
    """Checks that all changed HTML files start with the correct <!DOCTYPE html> tag.
    """
    results = []

    if input_api.no_diffs:
        return results

    wpt_path = input_api.os_path.join(input_api.PresubmitLocalPath(),
                                      "external", "wpt")

    for f in input_api.AffectedFiles(include_deletes=False):
        path = f.LocalPath()
        fname = input_api.os_path.basename(path)

        if not fname.endswith(".html") or "quirk" in fname:
            continue

        if not _IsDoctypeHTMLSet(f.NewContents()):
            error = "HTML file \"%s\" does not start with <!DOCTYPE html>. " \
                    "If you really intend to test in quirks mode, add \"quirk\" " \
                    "to the name of your test." % path

            if f.Action() == "A" or _IsDoctypeHTMLSet(f.OldContents()):
                # These tests are being imported from WPT, so <!DOCTYPE html> is
                # not required yet.
                no_errors = f.AbsoluteLocalPath().startswith(wpt_path)
                if no_errors:
                    results.append(output_api.PresubmitPromptWarning(error))
                else:
                    results.append(output_api.PresubmitError(error))

    return results


def _CheckNewVirtualSuites(input_api, output_api, max_suite_length: int = 48):
    """Validate new virtual test suites."""
    # TODO(crbug.com/1380165): Once all virtual suites adopt "owners", consider
    # making the field mandatory. In that case, we don't need to access the
    # change contents and can promote this check to `lint_test_expectations.py`.
    vts_path = input_api.os_path.join(input_api.PresubmitLocalPath(),
                                      'VirtualTestSuites')
    results = []
    for affected_file in input_api.AffectedFiles():
        if affected_file.AbsoluteLocalPath() != vts_path:
            continue
        old_contents = ''.join(affected_file.OldContents())
        new_contents = ''.join(affected_file.NewContents())
        try:
            old_suites = _FilterForSuites(input_api.json.loads(old_contents))
            new_suites = _FilterForSuites(input_api.json.loads(new_contents))
            old_suite_names = {suite['prefix'] for suite in old_suites}
            new_ownerless_suites, new_long_suites = [], []
            for suite in new_suites:
                prefix, owners = suite['prefix'], suite.get('owners', [])
                if prefix in old_suite_names:
                    continue
                if not owners:
                    new_ownerless_suites.append(prefix)
                if len(prefix) > max_suite_length:
                    new_long_suites.append(prefix)
            if new_ownerless_suites:
                results.append(
                    output_api.PresubmitPromptWarning(
                        'Consider specifying "owners" (a list of emails) '
                        'for the virtual suites added by this patch:',
                        new_ownerless_suites))
            if new_long_suites:
                results.append(
                    output_api.PresubmitPromptWarning(
                        'Consider shorter virtual suite names so that the '
                        "global filename length presubmit doesn't reject "
                        'future `*-expected.txt` under their directories. You '
                        'can add comments about these suites to '
                        'VirtualTestSuites.', new_long_suites))
        except (ValueError, KeyError):
            # Invalid JSON or missing required fields will be detected by
            # `lint_test_expectations.py`.
            pass
        break
    return results


def _FilterForSuites(suites):
    return [suite for suite in suites if not isinstance(suite, str)]


def CheckChangeOnUpload(input_api, output_api):
    results = []
    results.extend(_CheckTestharnessWdspecResults(input_api, output_api))
    results.extend(_CheckFilesUsingEventSender(input_api, output_api))
    results.extend(_CheckTestExpectations(input_api, output_api))
    # `_CheckTestExpectations()` updates the WPT manifests for
    # `_CheckForRedundantBaselines()`, so they must run in order. (Updating the
    # manifest is needed to correctly detect tests but takes 10-15s, so try
    # to only do so once; see crbug.com/1492238.)
    results.extend(_CheckForRedundantBaselines(input_api, output_api))
    results.extend(_CheckForJSTest(input_api, output_api))
    results.extend(_CheckForInvalidPreferenceError(input_api, output_api))
    results.extend(_CheckRunAfterLayoutAndPaintJS(input_api, output_api))
    results.extend(_CheckForUnlistedTestFolder(input_api, output_api))
    results.extend(_CheckForExtraVirtualBaselines(input_api, output_api))
    results.extend(_CheckWebViewExpectations(input_api, output_api))
    results.extend(_CheckForDoctypeHTML(input_api, output_api))
    results.extend(_CheckNewVirtualSuites(input_api, output_api))
    return results


def CheckChangeOnCommit(input_api, output_api):
    results = []
    results.extend(_CheckTestharnessWdspecResults(input_api, output_api))
    results.extend(_CheckFilesUsingEventSender(input_api, output_api))
    results.extend(_CheckTestExpectations(input_api, output_api))
    # `_CheckTestExpectations()` updates the WPT manifests for
    # `_CheckForRedundantBaselines()`, so they must run in order. (Updating the
    # manifest is needed to correctly detect tests but takes 10-15s, so try
    # to only do so once; see crbug.com/1492238.)
    results.extend(_CheckForRedundantBaselines(input_api, output_api))
    results.extend(_CheckForUnlistedTestFolder(input_api, output_api))
    results.extend(_CheckForExtraVirtualBaselines(input_api, output_api))
    results.extend(_CheckWebViewExpectations(input_api, output_api))
    results.extend(_CheckForDoctypeHTML(input_api, output_api))
    results.extend(_CheckNewVirtualSuites(input_api, output_api))
    return results