chromium/third_party/blink/tools/blinkpy/w3c/test_importer_unittest.py

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

import dataclasses
import json
import textwrap
import unittest
from unittest import mock

from blinkpy.common.checkout.git import (
    CommitRange,
    FileStatus,
    FileStatusType,
)
from blinkpy.common.checkout.git_mock import MockGit
from blinkpy.common.host_mock import MockHost
from blinkpy.common.net.git_cl import BuildStatus
from blinkpy.common.net.git_cl_mock import MockGitCL
from blinkpy.common.net.network_transaction import NetworkTimeout
from blinkpy.common.net.results_fetcher import Build
from blinkpy.common.path_finder import RELATIVE_WEB_TESTS
from blinkpy.common.system.executive_mock import MockCall
from blinkpy.common.system.executive_mock import MockExecutive
from blinkpy.common.system.log_testing import LoggingTestCase
from blinkpy.w3c.buganizer import BuganizerIssue
from blinkpy.w3c.chromium_commit_mock import MockChromiumCommit
from blinkpy.w3c.directory_owners_extractor import WPTDirMetadata
from blinkpy.w3c.import_notifier import DirectoryFailures, ImportNotifier
from blinkpy.w3c.local_wpt import LocalWPT
from blinkpy.w3c.local_wpt_mock import MockLocalWPT
from blinkpy.w3c.test_importer import TestImporter, ROTATIONS_URL, SHERIFF_EMAIL_FALLBACK, RUBBER_STAMPER_BOT
from blinkpy.w3c.wpt_github_mock import MockWPTGitHub
from blinkpy.w3c.wpt_manifest import BASE_MANIFEST_NAME
from blinkpy.web_tests.builder_list import BuilderList
from blinkpy.web_tests.models import typ_types
from unittest.mock import patch

MOCK_WEB_TESTS = '/mock-checkout/' + RELATIVE_WEB_TESTS
MANIFEST_INSTALL_CMD = [
    'python3',
    '/mock-checkout/third_party/wpt_tools/wpt/wpt',
    'manifest',
    '-v',
    '--no-download',
    f'--tests-root={MOCK_WEB_TESTS + "external/wpt"}',
    '--url-base=/',
]


class TestImporterTest(LoggingTestCase):

    def setUp(self):
        super().setUp()
        self.buganizer_client = mock.Mock()

    def mock_host(self):
        host = MockHost()
        host.builders = BuilderList({
            'cq-builder-a': {
                'port_name': 'linux-trusty',
                'specifiers': ['Trusty', 'Release'],
                'steps': {
                    'blink_web_tests': {},
                },
                'is_try_builder': True,
            },
            'cq-builder-b': {
                'port_name': 'mac-mac12',
                'specifiers': ['Mac12', 'Release'],
                'steps': {
                    'blink_web_tests': {},
                },
                'is_try_builder': True,
            },
            'cq-wpt-builder-c': {
                'port_name': 'linux-trusty',
                'specifiers': ['Trusty', 'Release'],
                'is_try_builder': True,
                'steps': {
                    'wpt_tests_suite': {},
                }
            },
            'CI Builder D': {
                'port_name': 'linux-trusty',
                'specifiers': ['Trusty', 'Release'],
            },
        })
        port = host.port_factory.get()
        MANIFEST_INSTALL_CMD[0] = port.python3_command()
        return host

    def _get_test_importer(self, host, github=None):
        port = host.port_factory.get()
        manifest = port.wpt_manifest('external/wpt')
        # Clear logs from manifest generation.
        self.logMessages().clear()
        return TestImporter(host,
                            github=github,
                            wpt_manifests=[manifest],
                            buganizer_client=self.buganizer_client)

    def test_update_expectations_for_cl_no_results(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host, time_out=True)
        success = importer.update_expectations_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering try jobs for updating expectations:\n',
            'INFO:   cq-builder-a\n',
            'INFO:   cq-builder-b\n',
            'INFO:   cq-wpt-builder-c\n',
            'ERROR: No initial try job results, aborting.\n',
        ])

    def test_update_expectations_for_cl_closed_cl(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host,
                                    status='closed',
                                    try_job_results={
                                        Build('builder-a', 123):
                                        BuildStatus.SUCCESS,
                                    })
        success = importer.update_expectations_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering try jobs for updating expectations:\n',
            'INFO:   cq-builder-a\n',
            'INFO:   cq-builder-b\n',
            'INFO:   cq-wpt-builder-c\n',
            'ERROR: The CL was closed, aborting.\n',
        ])

    def test_update_expectations_for_cl_all_jobs_pass(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host,
                                    status='lgtm',
                                    try_job_results={
                                        Build('builder-a', 123):
                                        BuildStatus.SUCCESS,
                                    })
        success = importer.update_expectations_for_cl()
        self.assertLog([
            'INFO: Triggering try jobs for updating expectations:\n',
            'INFO:   cq-builder-a\n',
            'INFO:   cq-builder-b\n',
            'INFO:   cq-wpt-builder-c\n',
            'INFO: All jobs finished.\n',
        ])
        self.assertTrue(success)

    def test_update_expectations_for_cl_fail_but_no_changes(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host,
                                    status='lgtm',
                                    try_job_results={
                                        Build('builder-a', 123):
                                        BuildStatus.FAILURE,
                                    })
        importer.fetch_new_expectations_and_baselines = lambda: None
        success = importer.update_expectations_for_cl()
        self.assertTrue(success)
        self.assertLog([
            'INFO: Triggering try jobs for updating expectations:\n',
            'INFO:   cq-builder-a\n',
            'INFO:   cq-builder-b\n',
            'INFO:   cq-wpt-builder-c\n',
            'INFO: All jobs finished.\n',
            'INFO: Skip Slow and Timeout tests.\n',
            'INFO: Generating MANIFEST.json\n',
        ])

    def test_run_commit_queue_for_cl_pass(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        # Only the latest job for each builder is counted.
        importer.git_cl = MockGitCL(host,
                                    status='lgtm',
                                    try_job_results={
                                        Build('cq-builder-a', 120):
                                        BuildStatus.FAILURE,
                                        Build('cq-builder-a', 123):
                                        BuildStatus.SUCCESS,
                                    })

        success = importer.run_commit_queue_for_cl()
        self.assertTrue(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'INFO: All jobs finished.\n',
            'INFO: CQ appears to have passed; sending to the rubber-stamper '
            'bot for CR+1 and commit.\n',
            'INFO: If the rubber-stamper bot rejects the CL, you either need '
            'to modify the benign file patterns, or manually CR+1 and land the '
            'import yourself if it touches code files. See https://chromium.'
            'googlesource.com/infra/infra/+/refs/heads/main/go/src/infra/'
            'appengine/rubber-stamper/README.md\n',
            'INFO: Update completed.\n',
        ])
        self.assertEqual(importer.git_cl.calls, [
            ['git', 'cl', 'try'],
            [
                'git', 'cl', 'upload', '-f', '--send-mail',
                '--enable-auto-submit', '--reviewers', RUBBER_STAMPER_BOT
            ],
        ])

    def test_run_commit_queue_for_cl_fail_cq(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host,
                                    status='lgtm',
                                    try_job_results={
                                        Build('cq-builder-a', 120):
                                        BuildStatus.SUCCESS,
                                        Build('cq-builder-a', 123):
                                        BuildStatus.FAILURE,
                                        Build('cq-builder-b', 200):
                                        BuildStatus.SUCCESS,
                                    })
        importer.fetch_new_expectations_and_baselines = lambda: None

        success = importer.run_commit_queue_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'INFO: All jobs finished.\n',
            'ERROR: CQ appears to have failed; aborting.\n',
        ])
        self.assertEqual(importer.git_cl.calls, [
            ['git', 'cl', 'try'],
        ])

    def test_run_commit_queue_for_cl_fail_to_land(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        # Only the latest job for each builder is counted.
        importer.git_cl = MockGitCL(host,
                                    status='lgtm',
                                    try_job_results={
                                        Build('cq-builder-a', 120):
                                        BuildStatus.FAILURE,
                                        Build('cq-builder-a', 123):
                                        BuildStatus.SUCCESS,
                                    })
        importer._need_sheriff_attention = lambda: False
        importer.git_cl.wait_for_closed_status = lambda timeout_seconds: False

        success = importer.run_commit_queue_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'INFO: All jobs finished.\n',
            'INFO: CQ appears to have passed; sending to the rubber-stamper '
            'bot for CR+1 and commit.\n',
            'INFO: If the rubber-stamper bot rejects the CL, you either need '
            'to modify the benign file patterns, or manually CR+1 and land the '
            'import yourself if it touches code files. See https://chromium.'
            'googlesource.com/infra/infra/+/refs/heads/main/go/src/infra/'
            'appengine/rubber-stamper/README.md\n',
            'ERROR: Cannot submit CL; aborting.\n',
        ])
        self.assertEqual(importer.git_cl.calls, [
            ['git', 'cl', 'try'],
            [
                'git', 'cl', 'upload', '-f', '--send-mail',
                '--enable-auto-submit', '--reviewers', RUBBER_STAMPER_BOT
            ],
        ])

    def test_run_commit_queue_for_cl_closed_cl(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host,
                                    status='closed',
                                    try_job_results={
                                        Build('cq-builder-a', 120):
                                        BuildStatus.SUCCESS,
                                        Build('cq-builder-b', 200):
                                        BuildStatus.SUCCESS,
                                    })

        success = importer.run_commit_queue_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'ERROR: The CL was closed; aborting.\n',
        ])
        self.assertEqual(importer.git_cl.calls, [
            ['git', 'cl', 'try'],
        ])

    def test_run_commit_queue_for_cl_timeout(self):
        # This simulates the case where we time out while waiting for try jobs.
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host, time_out=True)
        success = importer.run_commit_queue_for_cl()
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'ERROR: Timed out waiting for CQ; aborting.\n'
        ])
        self.assertEqual(importer.git_cl.calls, [['git', 'cl', 'try']])

    def test_submit_cl_timeout_and_already_merged(self):
        # Here we simulate a case where we timeout waiting for the CQ to submit a
        # CL because we miss the notification that it was merged. We then get an
        # error when trying to close the CL because it's already been merged.
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(
            host,
            status='lgtm',
            # Only the latest job for each builder is counted.
            try_job_results={
                Build('cq-builder-a', 120): BuildStatus.FAILURE,
                Build('cq-builder-a', 123): BuildStatus.SUCCESS,
            })
        importer._need_sheriff_attention = lambda: False
        importer.git_cl.wait_for_closed_status = lambda timeout_seconds: False
        success = importer.run_commit_queue_for_cl()
        # Since the CL is already merged, we absorb the error and treat it as success.
        self.assertFalse(success)
        self.assertLog([
            'INFO: Triggering CQ try jobs.\n',
            'INFO: All jobs finished.\n',
            'INFO: CQ appears to have passed; sending to the rubber-stamper '
            'bot for CR+1 and commit.\n',
            'INFO: If the rubber-stamper bot rejects the CL, you either need '
            'to modify the benign file patterns, or manually CR+1 and land the '
            'import yourself if it touches code files. See https://chromium.'
            'googlesource.com/infra/infra/+/refs/heads/main/go/src/infra/'
            'appengine/rubber-stamper/README.md\n',
            'ERROR: Cannot submit CL; aborting.\n',
        ])
        self.assertEqual(importer.git_cl.calls, [
            ['git', 'cl', 'try'],
            [
                'git', 'cl', 'upload', '-f', '--send-mail',
                '--enable-auto-submit', '--reviewers', RUBBER_STAMPER_BOT
            ],
        ])

    def test_apply_exportable_commits_locally(self):
        # TODO(robertma): Consider using MockLocalWPT.
        host = self.mock_host()
        importer = self._get_test_importer(
            host, github=MockWPTGitHub(pull_requests=[]))
        importer.wpt_git = MockGit(cwd='/tmp/wpt', executive=host.executive)
        fake_commit = MockChromiumCommit(
            host,
            subject='My fake commit',
            patch=('Fake patch contents...\n'
                   '--- a/' + RELATIVE_WEB_TESTS +
                   'external/wpt/css/css-ui-3/outline-004.html\n'
                   '+++ b/' + RELATIVE_WEB_TESTS +
                   'external/wpt/css/css-ui-3/outline-004.html\n'
                   '@@ -20,7 +20,7 @@\n'
                   '...'))
        importer.exportable_but_not_exported_commits = lambda _: [fake_commit]
        applied = importer.apply_exportable_commits_locally(LocalWPT(host))
        self.assertEqual(applied, [fake_commit])
        # This assertion is implementation details of LocalWPT.apply_patch.
        # TODO(robertma): Move this to local_wpt_unittest.py.
        self.assertEqual(host.executive.full_calls, [
            MockCall(MANIFEST_INSTALL_CMD,
                     kwargs={
                         'input': None,
                         'cwd': None,
                         'env': None
                     }),
            MockCall(
                ['git', 'apply', '-'], {
                    'input': ('Fake patch contents...\n'
                              '--- a/css/css-ui-3/outline-004.html\n'
                              '+++ b/css/css-ui-3/outline-004.html\n'
                              '@@ -20,7 +20,7 @@\n'
                              '...'),
                    'cwd':
                    '/tmp/wpt',
                    'env':
                    None
                }),
            MockCall(['git', 'add', '.'],
                     kwargs={
                         'input': None,
                         'cwd': '/tmp/wpt',
                         'env': None
                     })
        ])
        self.assertEqual(
            importer.wpt_git.local_commits(),
            [['Applying patch 14fd77e88e42147c57935c49d9e3b2412b8491b7']])

    def test_apply_exportable_commits_locally_returns_none_on_failure(self):
        host = self.mock_host()
        github = MockWPTGitHub(pull_requests=[])
        importer = self._get_test_importer(host, github=github)
        commit = MockChromiumCommit(host, subject='My fake commit')
        importer.exportable_but_not_exported_commits = lambda _: [commit]
        # Failure to apply patch.
        local_wpt = MockLocalWPT(apply_patch=['Failed'])
        applied = importer.apply_exportable_commits_locally(local_wpt)
        self.assertIsNone(applied)

    def test_get_directory_owners(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'external/wpt/foo/OWNERS',
            '[email protected]\n')
        importer = self._get_test_importer(host)
        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertEqual(importer.get_directory_owners(),
                         {('[email protected]', ): ['external/wpt/foo']})

    def test_get_directory_owners_no_changed_files(self):
        host = self.mock_host()
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'W3CImportExpectations', '')
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'external/wpt/foo/OWNERS',
            '[email protected]\n')
        importer = self._get_test_importer(host)
        self.assertEqual(importer.get_directory_owners(), {})

    def test_delete_orphaned_baselines(self):
        orphaned_baselines = {
            'external/wpt/dir/variants_orphaned-expected.txt',
            'platform/mac/virtual/fake-vts/'
            'external/wpt/dir/variants_orphaned-expected.txt',
            'external/wpt/orphaned-expected.txt',
            'flag-specific/fake-flag/external/wpt/orphaned-expected.txt',
        }
        valid_baselines = {
            'not-a-wpt-expected.txt',
            'external/wpt/dir/variants_not-orphaned-expected.txt',
            'external/wpt/not-orphaned-expected.txt',
        }

        host = self.mock_host()
        fs = host.filesystem
        manifest = {
            'items': {
                'testharness': {
                    'dir': {
                        'variants.html': [
                            '89ab',
                            ['dir/variants.html?not-orphaned', {}],
                        ],
                    },
                },
                'wdspec': {
                    'not-orphaned.py': ['cdef', [None, {}]],
                },
            },
        }
        fs.write_text_file(MOCK_WEB_TESTS + 'external/wpt/MANIFEST.json',
                           json.dumps(manifest))
        for baseline in [*orphaned_baselines, *valid_baselines]:
            fs.write_text_file(MOCK_WEB_TESTS + baseline, '')

        port = host.port_factory.get('test-linux-trusty')
        importer = TestImporter(host, buganizer_client=mock.Mock())
        with mock.patch.object(host.port_factory, 'get', return_value=port):
            importer.delete_orphaned_baselines()

        self.assertLog(['INFO: Deleted 4 orphaned baseline(s).\n'])
        for baseline in orphaned_baselines:
            self.assertFalse(fs.exists(MOCK_WEB_TESTS + baseline),
                             f'{baseline!r} should not exist')
        for baseline in valid_baselines:
            self.assertTrue(fs.exists(MOCK_WEB_TESTS + baseline),
                            f'{baseline!r} should exist')

    # Tests for protected methods - pylint: disable=protected-access

    def test_commit_changes(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer._commit_changes('dummy message')
        self.assertEqual(importer.project_git.local_commits(),
                         [['dummy message']])

    def test_commit_message(self):
        importer = self._get_test_importer(self.mock_host())
        self.assertEqual(
            importer.commit_message('aaaa',
                                    CommitRange('0123456789', 'a123456789')),
            textwrap.dedent("""\
                Import wpt@a123456789

                https://github.com/web-platform-tests/wpt/compare/012345678...a12345678

                Using wpt-import in Chromium aaaa.
                """))

    def test_commit_message_with_pending_exportable_changes(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        locally_applied_commits = [
            MockChromiumCommit(host, subject='Pending export 1'),
            MockChromiumCommit(
                host,
                'refs/heads/main@{#222)',
                subject=f'Pending export 2 with very long subject {"a" * 80}'),
        ]
        self.assertEqual(
            importer.commit_message('aaaa', CommitRange('0000', '1111'),
                                    locally_applied_commits),
            textwrap.dedent("""\
                Import wpt@1111

                https://github.com/web-platform-tests/wpt/compare/0000...1111

                Using wpt-import in Chromium aaaa.
                With Chromium commits locally applied on WPT:
                  14fd77e88e "Pending export 1"
                  3e977a7ce6 "Pending export 2 with very long subject [...]
                """)),

    def test_cl_description_with_empty_environ(self):
        host = self.mock_host()
        host.executive = MockExecutive(output='Last commit message\n\n')
        importer = self._get_test_importer(host)
        description = importer.cl_description(directory_owners={})
        self.assertEqual(
            description,
            textwrap.dedent("""\
                Last commit message

                Note to gardeners: This CL imports external tests and adds expectations
                for those tests; if this CL is large and causes a few new failures,
                please fix the failures by adding new lines to TestExpectations rather
                than reverting. See:
                https://chromium.googlesource.com/chromium/src/+/main/docs/testing/web_platform_tests.md

                NOAUTOREVERT=true
                No-Export: true
                Validate-Test-Flakiness: skip
                Cq-Include-Trybots: luci.chromium.try:linux-blink-rel
                """))
        self.assertEqual(host.executive.calls, [MANIFEST_INSTALL_CMD] +
                         [['git', 'log', '-1', '--format=%B']])

    def test_cl_description_with_directory_owners(self):
        host = self.mock_host()
        host.executive = MockExecutive(output='Last commit message\n\n')
        importer = self._get_test_importer(host)
        description = importer.cl_description(
            directory_owners={
                ('[email protected]', ):
                ['external/wpt/foo', 'external/wpt/bar'],
                ('[email protected]', '[email protected]'): ['external/wpt/baz'],
            })
        self.assertIn(
            'Directory owners for changes in this CL:\n'
            '[email protected]:\n'
            '  external/wpt/foo\n'
            '  external/wpt/bar\n'
            '[email protected], [email protected]:\n'
            '  external/wpt/baz\n\n', description)

    def test_sheriff_email_no_response_uses_backup(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        self.assertEqual(SHERIFF_EMAIL_FALLBACK, importer.sheriff_email())
        self.assertLog([
            'ERROR: Exception while fetching current sheriff: '
            'Expecting value: line 1 column 1 (char 0)\n'
        ])

    def test_sheriff_email_no_emails_field(self):
        host = self.mock_host()
        host.web.urls[ROTATIONS_URL] = json.dumps(
            {'updated_unix_timestamp': '1591108191'})
        importer = self._get_test_importer(host)
        self.assertEqual(SHERIFF_EMAIL_FALLBACK, importer.sheriff_email())
        self.assertLog([
            'ERROR: No email found for current sheriff. Retrieved content: %s\n'
            % host.web.urls[ROTATIONS_URL]
        ])

    def test_sheriff_email_nobody_on_rotation(self):
        host = self.mock_host()
        host.web.urls[ROTATIONS_URL] = json.dumps({
            'emails': [],
            'updated_unix_timestamp':
            '1591108191'
        })
        importer = self._get_test_importer(host)
        self.assertEqual(SHERIFF_EMAIL_FALLBACK, importer.sheriff_email())
        self.assertLog([
            'ERROR: No email found for current sheriff. Retrieved content: %s\n'
            % host.web.urls[ROTATIONS_URL]
        ])

    def test_sheriff_email_rotations_url_unavailable(self):
        def raise_exception(*_):
            raise NetworkTimeout

        host = self.mock_host()
        host.web.get_binary = raise_exception
        importer = self._get_test_importer(host)
        self.assertEqual(SHERIFF_EMAIL_FALLBACK, importer.sheriff_email())
        self.assertLog([
            'ERROR: Cannot fetch %s\n' % ROTATIONS_URL,
        ])

    def test_sheriff_email(self):
        host = self.mock_host()
        host.web.urls[ROTATIONS_URL] = json.dumps({
            'emails': ['[email protected]'],
            'updated_unix_timestamp':
            '1591108191',
        })
        importer = self._get_test_importer(host)
        self.assertEqual('[email protected]',
                         importer.sheriff_email())
        self.assertLog([])

    def test_generate_manifest_successful_run(self):
        # This test doesn't test any aspect of the real manifest script, it just
        # asserts that TestImporter._generate_manifest would invoke the script.
        host = self.mock_host()
        importer = self._get_test_importer(host)
        host.filesystem.write_text_file(
            MOCK_WEB_TESTS + 'external/wpt/MANIFEST.json', '{}')
        importer._generate_manifest()
        self.assertEqual(host.executive.calls, [MANIFEST_INSTALL_CMD] * 2)
        self.assertEqual(importer.project_git.added_paths,
                         {MOCK_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME})

    def test_has_wpt_changes(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertTrue(importer._has_wpt_changes())

        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'TestExpectations':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertFalse(importer._has_wpt_changes())

        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertFalse(importer._has_wpt_changes())

    def test_file_and_record_bugs_update_bugs(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host)

        git, fs = importer.project_git, host.filesystem
        fs.write_text_file(MOCK_WEB_TESTS + 'external/wpt/foo/DIR_METADATA',
                           '')
        git.new_branch('update_wpt')
        exp_path = MOCK_WEB_TESTS + 'TestExpectations'
        fs.write_text_file(
            exp_path,
            textwrap.dedent("""\
                # results: [ Failure Pass Timeout ]
                # tags: [ Linux Mac ]
                crbug.com/555 [ Mac ] external/wpt/foo/new-for-platform.html [ Failure ]
                """))
        git.add_list([exp_path])
        git.commit_locally_with_message(f'Import wpt@{"e" * 40}')
        fs.write_text_file(
            exp_path,
            textwrap.dedent("""\
                # results: [ Failure Pass Timeout ]
                # tags: [ Linux Mac ]
                external/wpt/foo/new.html [ Failure ]
                [ Linux ] external/wpt/foo/new-for-platform.html [ Failure ]
                crbug.com/555 [ Mac ] external/wpt/foo/new-for-platform.html [ Failure ]
                # Manually added expectation with existing bug
                crbug.com/444 external/wpt/foo/do-not-modify.html [ Failure ]
                """))
        git.add_list([exp_path])
        git.commit_locally_with_message(f'Import wpt@{"f" * 40}')

        local_wpt = MockLocalWPT()
        gerrit_cl = mock.Mock(messages=[], number=999)
        gerrit_api = mock.Mock()
        gerrit_api.query_cls.return_value = [gerrit_cl]
        self.buganizer_client.NewIssue.side_effect = lambda issue: BuganizerIssue(
            **{
                **dataclasses.asdict(issue),
                'issue_id': 111,
            })
        notifier = ImportNotifier(host, git, local_wpt, gerrit_api,
                                  self.buganizer_client)
        with mock.patch(
                'blinkpy.w3c.import_notifier.'
                'DirectoryOwnersExtractor.read_dir_metadata',
                return_value=WPTDirMetadata(should_notify=True)):
            importer.file_and_record_bugs(notifier)

        gerrit_cl.post_comment.assert_called_once_with(
            'Filed bugs for failures introduced by this CL: '
            'https://crbug.com/111')
        self.buganizer_client.NewIssue.assert_called_once()
        self.assertEqual(
            git.show_blob(RELATIVE_WEB_TESTS + 'TestExpectations',
                          'HEAD').decode(),
            textwrap.dedent("""\
                # results: [ Failure Pass Timeout ]
                # tags: [ Linux Mac ]
                crbug.com/111 external/wpt/foo/new.html [ Failure ]
                crbug.com/111 [ Linux ] external/wpt/foo/new-for-platform.html [ Failure ]
                crbug.com/555 [ Mac ] external/wpt/foo/new-for-platform.html [ Failure ]
                # Manually added expectation with existing bug
                crbug.com/444 external/wpt/foo/do-not-modify.html [ Failure ]
                """))
        expected_message = textwrap.dedent("""\
            Update `TestExpectations` with bugs filed for crrev.com/c/999

            Bug: 111
            """)
        self.assertEqual([[
            'git',
            'cl',
            'upload',
            '--bypass-hooks',
            '-f',
            f'--message={expected_message}',
            '--send-mail',
            '--enable-auto-submit',
            '[email protected]',
        ]], importer.git_cl.calls)
        self.assertEqual(git.tracking_branch, 'update_wpt')
        self.assertNotEqual(git.current_branch(), 'update_wpt')

    def test_file_and_record_bugs_no_upload_reformat(self):
        """Do not create CLs for cosmetic-only changes."""
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host)

        git, fs = importer.project_git, host.filesystem
        fs.write_text_file(MOCK_WEB_TESTS + 'external/wpt/foo/DIR_METADATA',
                           '')
        git.new_branch('update_wpt')
        exp_path = MOCK_WEB_TESTS + 'TestExpectations'
        contents = textwrap.dedent("""\
            # results: [ Failure Pass Timeout ]
            # Serializing this file will sort the statuses.
            crbug.com/123 external/wpt/no-change.html [ Timeout Failure ]
            """)
        fs.write_text_file(exp_path, contents)
        git.add_list([MOCK_WEB_TESTS])
        git.commit_locally_with_message(f'Import wpt@{"e" * 40}')
        git.commit_locally_with_message(f'Import wpt@{"f" * 40}')

        local_wpt = MockLocalWPT()
        gerrit_api = mock.Mock()
        gerrit_api.query_cls.return_value = [mock.Mock(messages=[])]
        notifier = ImportNotifier(host, git, local_wpt, gerrit_api,
                                  self.buganizer_client)
        with mock.patch(
                'blinkpy.w3c.import_notifier.'
                'DirectoryOwnersExtractor.read_dir_metadata',
                return_value=WPTDirMetadata(should_notify=True)):
            importer.file_and_record_bugs(notifier)

        self.assertEqual(
            git.show_blob(RELATIVE_WEB_TESTS + 'TestExpectations',
                          'HEAD').decode(), contents)
        self.assertEqual(importer.git_cl.calls, [])
        self.assertEqual(git.current_branch(), 'update_wpt')

    def test_file_and_record_bugs_notify_on_timeout(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.git_cl = MockGitCL(host, status='commit', time_out=True)

        git, fs = importer.project_git, host.filesystem
        exp_path = MOCK_WEB_TESTS + 'TestExpectations'
        fs.write_text_file(
            exp_path,
            textwrap.dedent("""\
                # results: [ Pass Failure ]
                external/wpt/foo/new.html [ Failure ]
                """))
        git.add_list([MOCK_WEB_TESTS])
        git.commit_locally_with_message(f'Import wpt@{"e" * 40}')

        # For this test, don't actually simulate bug filing.
        exp = typ_types.Expectation(test='external/wpt/foo/new.html',
                                    results=frozenset(
                                        [typ_types.ResultType.Failure]))
        notifier = mock.Mock(default_port=host.port_factory.get('test'))
        notifier.new_failures_by_directory = {
            'external/wpt/foo': DirectoryFailures({exp_path: [exp]}),
        }
        notifier.main.return_value = {
            'external/wpt/foo': BuganizerIssue('New failures', '', '', 111),
        }, mock.Mock()

        with importer:
            importer.file_and_record_bugs(notifier)
        self.assertLog([
            'INFO: Filing bugs for the last WPT import.\n',
            'INFO: Committing changes.\n',
            'INFO: Uploading change list.\n',
            'INFO: Issue: https://crrev.com/c/1234\n',
            'WARNING: Failed to automatically submit https://crrev.com/c/1234. '
            'Pinging https://crbug.com/111 for help.\n',
        ])
        self.buganizer_client.NewComment.assert_called_once_with(111, mock.ANY)
        _, message = self.buganizer_client.NewComment.call_args.args
        self.assertIn('https://crrev.com/c/1234 backfills TestExpectations',
                      message)
        self.assertIn(['git', 'cl', 'set-close'], importer.git_cl.calls)

    def test_find_insert_index_ignore_pattern_empty_list(self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = []
        insert_key = "test1"

        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, insert_key)

        self.assertEqual(insert_index, 0)

    def test_find_insert_index_ignore_pattern_with_duplicate(self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = ["test1", "test2", "# test3", "test4", "test5"]
        insert_key = "test2"

        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, insert_key)

        self.assertEqual(insert_index, 1)

    def test_find_insert_index_ignore_comments_with_middle_start_index(self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = ["test1", "test2", "test3", "test4", "test5"]
        insert_key = "test0"
        start_index = 2

        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, insert_key, start_index)

        self.assertEqual(insert_index, 2)

    def test_find_insert_index_ignore_comments_start_index_equal_to_list_length(
            self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = ["test1", "test2", "test3", "test4", "test5"]

        # smaller than last item
        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, "test3", 5)
        self.assertEqual(insert_index, 5)

        # larger than last item

        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, "test9", 5)
        self.assertEqual(insert_index, 5)

    def test_find_insert_index_ignore_comments_start_index_equal_to_last_index(
            self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = ["test1", "test2", "test3", "test4", "test5"]

        # smaller than last item
        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, "test3", 4)
        self.assertEqual(insert_index, 4)

        # larger than last item
        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, "test9", 4)
        self.assertEqual(insert_index, 5)

    def test_find_insert_index_ignore_pattern(self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        targets_list = ["test1", "# test3", "test4", "test5"]
        insert_key = "test2"
        filter = lambda key: key.startswith("test")

        insert_index = test_importer.find_insert_index_ignore_comments(
            targets_list, insert_key)

        self.assertEqual(insert_index, 2)

    def test_update_testlist_lines(self):
        host = self.mock_host()
        test_importer = self._get_test_importer(host)

        testlist_lines = [
            "# comment",
            "external/wpt/test1.html",
            "# comment",
            "external/wpt/test2.html",
            "# comment",
            "external/wpt/test3.html",
            "# comment",
        ]
        added_tests = ["external/wpt/test4.html", "external/wpt/test5.html"]
        deleted_tests = ["external/wpt/test2.html"]

        new_testlist_lines = test_importer.update_testlist_lines(
            testlist_lines, added_tests, deleted_tests)

        expected_new_testlist_lines = [
            "# comment",
            "external/wpt/test1.html",
            "# comment",
            "# comment",
            "external/wpt/test3.html",
            "external/wpt/test4.html",
            "external/wpt/test5.html",
            "# comment",
        ]

        self.assertEqual(new_testlist_lines, expected_new_testlist_lines)

    def test_update_testlist_with_idlharness_changes(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)

        def _git_added_files():
            return [
                MOCK_WEB_TESTS + "external/wpt/2_added_idlharness.html",
                MOCK_WEB_TESTS + "external/wpt/3_duplicate_idlharness.html",
                MOCK_WEB_TESTS + "external/wpt/4_new_idlharness.html",
            ]

        def _git_deleted_files():
            return [
                MOCK_WEB_TESTS + "external/wpt/5_old_idlharness.html",
                MOCK_WEB_TESTS + "external/wpt/6_deleted_idlharness.html",
            ]

        importer.project_git.added_files = _git_added_files
        importer.project_git.deleted_files = _git_deleted_files
        importer.project_git._relative_to_web_test_dir = \
            lambda test_path: test_path
        testlist_path = importer.finder.path_from_web_tests(
            "TestLists", "android.filter")
        test_list_lines = [
            'external/wpt/1_first_idlharness.html',
            'external/wpt/3_duplicate_idlharness.html',
            'external/wpt/5_old_idlharness.html',
            'external/wpt/6_deleted_idlharness.html',
            'external/wpt/7_last_idlharness.html',
        ]
        expected_test_list_lines = [
            'external/wpt/1_first_idlharness.html',
            'external/wpt/2_added_idlharness.html',
            'external/wpt/3_duplicate_idlharness.html',
            'external/wpt/4_new_idlharness.html',
            'external/wpt/7_last_idlharness.html',
        ]
        host.filesystem.write_text_file(testlist_path,
                                        "\n".join(test_list_lines))
        with patch.object(importer.project_git, "run") as mock_git_run:
            importer.update_testlist_with_idlharness_changes(testlist_path)
            actual_test_list_lines = host.filesystem.open_text_file_for_reading(
                testlist_path).read().split("\n")
            self.assertEqual(actual_test_list_lines, expected_test_list_lines)
            mock_git_run.assert_called_with(['add', testlist_path])

    def test_update_testlist_with_idlharness_changes_with_comment(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)

        def _git_added_files():
            return [
                MOCK_WEB_TESTS + "external/wpt/9_added_idlharness.html",
            ]

        def _git_deleted_files():
            return []

        importer.project_git.added_files = _git_added_files
        importer.project_git.deleted_files = _git_deleted_files
        importer.project_git._relative_to_web_test_dir = \
            lambda test_path: test_path
        testlist_path = importer.finder.path_from_web_tests(
            "TestLists", "android.filter")
        test_list_lines = [
            '# comment 1',
            'external/wpt/1_first_idlharness.html',
            '# comment 2',
            'external/wpt/7_last_idlharness.html',
            '# comment 3',
        ]
        expected_test_list_lines = [
            '# comment 1',
            'external/wpt/1_first_idlharness.html',
            '# comment 2',
            'external/wpt/7_last_idlharness.html',
            "external/wpt/9_added_idlharness.html",
            '# comment 3',
        ]
        host.filesystem.write_text_file(testlist_path,
                                        "\n".join(test_list_lines))
        with patch.object(importer.project_git, "run") as mock_git_run:
            importer.update_testlist_with_idlharness_changes(testlist_path)
            actual_test_list_lines = host.filesystem.open_text_file_for_reading(
                testlist_path).read().split("\n")
            self.assertEqual(actual_test_list_lines, expected_test_list_lines)
            mock_git_run.assert_called_with(['add', testlist_path])

    def test_need_sheriff_attention(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertFalse(importer._need_sheriff_attention())

        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/y.sh':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertTrue(importer._need_sheriff_attention())

        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/y.py':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertTrue(importer._need_sheriff_attention())

        importer.project_git.changed_files = lambda: {
            RELATIVE_WEB_TESTS + 'external/' + BASE_MANIFEST_NAME:
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/x.html':
            FileStatus(FileStatusType.MODIFY),
            RELATIVE_WEB_TESTS + 'external/wpt/foo/y.bat':
            FileStatus(FileStatusType.MODIFY),
        }
        self.assertTrue(importer._need_sheriff_attention())

    # TODO(crbug.com/800570): Fix orphan baseline finding in the presence of
    # variant tests.
    @unittest.skip('Finding orphaned baselines is broken')
    def test_delete_orphaned_baselines_basic(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        dest_path = importer.dest_path
        host.filesystem.write_text_file(
            dest_path + '/MANIFEST.json',
            json.dumps({
                'items': {
                    'testharness': {
                        'a.html': ['abcdef123', [None, {}]],
                    },
                    'manual': {},
                    'reftest': {},
                },
            }))
        host.filesystem.write_text_file(dest_path + '/a.html', '')
        host.filesystem.write_text_file(dest_path + '/a-expected.txt', '')
        host.filesystem.write_text_file(dest_path + '/orphaned-expected.txt',
                                        '')
        importer._delete_orphaned_baselines()
        self.assertFalse(
            host.filesystem.exists(dest_path + '/orphaned-expected.txt'))
        self.assertTrue(host.filesystem.exists(dest_path + '/a-expected.txt'))

    # TODO(crbug.com/800570): Fix orphan baseline finding in the presence of
    # variant tests.
    @unittest.skip('Finding orphaned baselines is broken')
    def test_delete_orphaned_baselines_worker_js_tests(self):
        # This test checks that baselines for existing tests shouldn't be
        # deleted, even if the test name isn't the same as the file name.
        host = self.mock_host()
        importer = self._get_test_importer(host)
        dest_path = importer.dest_path
        host.filesystem.write_text_file(
            dest_path + '/MANIFEST.json',
            json.dumps({
                'items': {
                    'testharness': {
                        'a.any.js': [
                            'abcdef123',
                            ['a.any.html', {}],
                            ['a.any.worker.html', {}],
                        ],
                        'b.worker.js': ['abcdef123', ['b.worker.html', {}]],
                        'c.html': [
                            'abcdef123',
                            ['c.html?q=1', {}],
                            ['c.html?q=2', {}],
                        ],
                    },
                    'manual': {},
                    'reftest': {},
                },
            }))
        host.filesystem.write_text_file(dest_path + '/a.any.js', '')
        host.filesystem.write_text_file(dest_path + '/a.any-expected.txt', '')
        host.filesystem.write_text_file(
            dest_path + '/a.any.worker-expected.txt', '')
        host.filesystem.write_text_file(dest_path + '/b.worker.js', '')
        host.filesystem.write_text_file(dest_path + '/b.worker-expected.txt',
                                        '')
        host.filesystem.write_text_file(dest_path + '/c.html', '')
        host.filesystem.write_text_file(dest_path + '/c-expected.txt', '')
        importer._delete_orphaned_baselines()
        self.assertTrue(
            host.filesystem.exists(dest_path + '/a.any-expected.txt'))
        self.assertTrue(
            host.filesystem.exists(dest_path + '/a.any.worker-expected.txt'))
        self.assertTrue(
            host.filesystem.exists(dest_path + '/b.worker-expected.txt'))
        self.assertTrue(host.filesystem.exists(dest_path + '/c-expected.txt'))

    def test_clear_out_dest_path(self):
        host = self.mock_host()
        importer = self._get_test_importer(host)
        dest_path = importer.dest_path
        host.filesystem.write_text_file(dest_path + '/foo-test.html', '')
        host.filesystem.write_text_file(dest_path + '/foo-test-expected.txt',
                                        '')
        host.filesystem.write_text_file(dest_path + '/OWNERS', '')
        host.filesystem.write_text_file(dest_path + '/DIR_METADATA', '')
        host.filesystem.write_text_file(dest_path + '/bar/baz/OWNERS', '')
        # When the destination path is cleared, OWNERS files and baselines
        # are kept.
        importer._clear_out_dest_path()
        self.assertFalse(host.filesystem.exists(dest_path + '/foo-test.html'))
        self.assertTrue(
            host.filesystem.exists(dest_path + '/foo-test-expected.txt'))
        self.assertTrue(host.filesystem.exists(dest_path + '/OWNERS'))
        self.assertTrue(host.filesystem.exists(dest_path + '/DIR_METADATA'))
        self.assertTrue(host.filesystem.exists(dest_path + '/bar/baz/OWNERS'))