chromium/tools/bisect_test.py

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

import functools
import io
import json
import os
import re
import subprocess
import sys
import unittest
from unittest.mock import ANY, Mock, MagicMock, mock_open, patch, call

bisect_builds = __import__('bisect-builds')

if 'NO_MOCK_SERVER' not in os.environ:
  maybe_patch = patch
else:
  # SetupEnvironment for gsutil to connect to real server.
  options, _ = bisect_builds.ParseCommandLine(['-a', 'linux64'])
  bisect_builds.SetupEnvironment(options)
  bisect_builds.SetupAndroidEnvironment()

  # Mock object that always wraps for the spec.
  # This will pass the call through and ignore the return_value and side_effect.
  class WrappedMock(MagicMock):

    def __init__(self,
                 spec=None,
                 return_value=None,
                 side_effect=None,
                 *args,
                 **kwargs):
      wraps = kwargs.pop('wraps', spec)
      super().__init__(spec, *args, **kwargs, wraps=wraps)

  maybe_patch = functools.partial(patch, spec=True, new_callable=WrappedMock)
  maybe_patch.object = functools.partial(patch.object,
                                         spec=True,
                                         new_callable=WrappedMock)


class BisectTestCase(unittest.TestCase):

  @classmethod
  def setUpClass(cls):
    if sys.version_info[:2] <= (3, 8):
      return
    # Patch the name pattern for pkgutil to accept "bisect-builds" as module
    # name.
    dotted_words = r'(?!\d)([\w-]+)(\.(?!\d)(\w+))*'
    name_pattern = re.compile(
        f'^(?P<pkg>{dotted_words})'
        f'(?P<cln>:(?P<obj>{dotted_words})?)?$', re.UNICODE)
    cls.name_pattern_patcher = patch('pkgutil._NAME_PATTERN', name_pattern)
    cls.name_pattern_patcher.start()

  @classmethod
  def tearDownClass(cls):
    if sys.version_info[:2] <= (3, 8):
      return
    cls.name_pattern_patcher.stop()


class BisectTest(BisectTestCase):

  max_rev = 10000

  def bisect(self, good_rev, bad_rev, evaluate, num_runs=1):
    options, args = bisect_builds.ParseCommandLine([
        '-a', 'linux64', '-g', good_rev, '-b', bad_rev, '--times',
        str(num_runs)
    ])
    archive_build = bisect_builds.create_archive_build(options)
    (minrev, maxrev) = bisect_builds.Bisect(archive_build=archive_build,
                                            evaluate=evaluate,
                                            try_args=args)
    return (minrev, maxrev)

  @patch('bisect-builds.DownloadJob._fetch')
  @patch('bisect-builds.ArchiveBuild.run_revision', return_value=(0, '', ''))
  @patch('bisect-builds.SnapshotBuild._get_rev_list',
         return_value=range(max_rev))
  def testBisectConsistentAnswer(self, mock_get_rev_list, mock_run_revision,
                                 mock_fetch):
    self.assertEqual(self.bisect(1000, 100, lambda *args: 'g'), (100, 101))
    self.assertEqual(self.bisect(100, 1000, lambda *args: 'b'), (100, 101))
    self.assertEqual(self.bisect(2000, 200, lambda *args: 'b'), (1999, 2000))
    self.assertEqual(self.bisect(200, 2000, lambda *args: 'g'), (1999, 2000))


class DownloadJobTest(BisectTestCase):

  @patch('bisect-builds.gsutil_download')
  def test_fetch_gsutil(self, mock_gsutil_download):
    fetch = bisect_builds.DownloadJob('gs://some-file.zip', 123)
    fetch.start()
    fetch.wait_for()
    mock_gsutil_download.assert_called_once()

  @patch('urllib.request.urlretrieve')
  def test_fetch_http(self, mock_urlretrieve):
    fetch = bisect_builds.DownloadJob('http://some-file.zip', 123)
    fetch.start()
    fetch.wait_for()
    mock_urlretrieve.assert_called_once()

  @patch('tempfile.mkstemp', return_value=(321, 'some-file.zip'))
  @patch('urllib.request.urlretrieve')
  @patch('os.close')
  @patch('os.unlink')
  def test_should_del(self, mock_unlink, mock_close, mock_urlretrieve,
                      mock_mkstemp):
    fetch = bisect_builds.DownloadJob('http://some-file.zip', 123)
    fetch.start().wait_for()
    fetch.stop()
    mock_mkstemp.assert_called_once()
    mock_close.assert_called_once()
    mock_urlretrieve.assert_called_once()
    mock_unlink.assert_called_with('some-file.zip')

  @patch('urllib.request.urlretrieve')
  def test_stop_wait_for_should_be_able_to_reenter(self, mock_urlretrieve):
    fetch = bisect_builds.DownloadJob('http://some-file.zip', 123)
    fetch.start()
    fetch.wait_for()
    fetch.wait_for()
    fetch.stop()
    fetch.stop()

  @patch('tempfile.mkstemp',
         side_effect=[(321, 'some-file.apks'), (123, 'file2.apk')])
  @patch('bisect-builds.gsutil_download')
  @patch('os.close')
  @patch('os.unlink')
  def test_should_support_multiple_files(self, mock_unlink, mock_close,
                                         mock_gsutil, mock_mkstemp):
    urls = {
        'trichrome':
        ('gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/high-arm_64/'
         'TrichromeChromeGoogle6432Stable.apks'),
        'trichrome_library':
        ('gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/high-arm_64/'
         'TrichromeLibraryGoogle6432Stable.apk'),
    }
    fetch = bisect_builds.DownloadJob(urls, 123)
    result = fetch.start().wait_for()
    fetch.stop()
    self.assertDictEqual(result, {
        'trichrome': 'some-file.apks',
        'trichrome_library': 'file2.apk',
    })
    self.assertEqual(mock_mkstemp.call_count, 2)
    self.assertEqual(mock_close.call_count, 2)
    self.assertEqual(mock_unlink.call_count, 2)
    self.assertEqual(mock_gsutil.call_count, 2)


class ArchiveBuildTest(BisectTestCase):

  def setUp(self):
    self.patcher = patch.multiple(
        bisect_builds.ArchiveBuild,
        __abstractmethods__=set(),
        build_type='release',
        _get_rev_list=Mock(return_value=list(map(str, range(10)))),
        _rev_list_cache_key='abc')
    self.patcher.start()

  def tearDown(self):
    self.patcher.stop()

  def create_build(self, args=None):
    if args is None:
      args = ['-a', 'linux64', '-g', '0', '-b', '9']
    options, args = bisect_builds.ParseCommandLine(args)
    return bisect_builds.ArchiveBuild(options)

  def test_cache_should_not_work_if_not_enabled(self):
    build = self.create_build()
    self.assertFalse(build.use_local_cache)
    with patch('builtins.open') as m:
      self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
      bisect_builds.ArchiveBuild._get_rev_list.assert_called_once()
      m.assert_not_called()

  def test_cache_should_save_and_load(self):
    build = self.create_build(
        ['-a', 'linux64', '-g', '0', '-b', '9', '--use-local-cache'])
    self.assertTrue(build.use_local_cache)
    # Load the non-existent cache and write to it.
    cached_data = []
    # The cache file would be opened 3 times:
    #   1. read by _load_rev_list_cache
    #   2. read by _save_rev_list_cache for existing cache
    #   3. write by _save_rev_list_cache
    write_mock = MagicMock()
    write_mock.__enter__().write.side_effect = lambda d: cached_data.append(d)
    with patch('builtins.open',
               side_effect=[FileNotFoundError, FileNotFoundError, write_mock]):
      self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
      bisect_builds.ArchiveBuild._get_rev_list.assert_called_once()
    cached_json = json.loads(''.join(cached_data))
    self.assertDictEqual(cached_json, {'abc': [str(x) for x in range(10)]})
    # Load cache with cached data.
    build = self.create_build(
        ['-a', 'linux64', '-g', '0', '-b', '9', '--use-local-cache'])
    bisect_builds.ArchiveBuild._get_rev_list.reset_mock()
    with patch('builtins.open', mock_open(read_data=''.join(cached_data))):
      self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
      bisect_builds.ArchiveBuild._get_rev_list.assert_not_called()

  @patch.object(bisect_builds.ArchiveBuild, '_load_rev_list_cache')
  @patch.object(bisect_builds.ArchiveBuild, '_save_rev_list_cache')
  @patch.object(bisect_builds.ArchiveBuild,
                '_get_rev_list',
                return_value=[str(x) for x in range(10)])
  def test_should_request_partial_rev_list(self, mock_get_rev_list,
                                           mock_save_rev_list_cache,
                                           mock_load_rev_list_cache):
    build = self.create_build()
    # missing latest
    mock_load_rev_list_cache.return_value = [str(x) for x in range(5)]
    self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
    mock_get_rev_list.assert_called_with('4', '9')
    # missing old and latest
    mock_load_rev_list_cache.return_value = [str(x) for x in range(1, 5)]
    self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
    mock_get_rev_list.assert_called_with('0', '9')
    # missing old
    mock_load_rev_list_cache.return_value = [str(x) for x in range(3, 10)]
    self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
    mock_get_rev_list.assert_called_with('0', '3')
    # no intersect
    mock_load_rev_list_cache.return_value = ['c', 'd', 'e']
    self.assertEqual(build.get_rev_list(), [str(x) for x in range(10)])
    mock_save_rev_list_cache.assert_called_with([str(x) for x in range(10)] +
                                                ['c', 'd', 'e'])
    mock_get_rev_list.assert_called_with('0', 'c')

  @patch.object(bisect_builds.ArchiveBuild, '_get_rev_list', return_value=[])
  def test_should_raise_error_when_no_rev_list(self, mock_get_rev_list):
    build = self.create_build()
    with self.assertRaises(bisect_builds.BisectException):
      build.get_rev_list()
    mock_get_rev_list.assert_any_call('0', '9')
    mock_get_rev_list.assert_any_call()

  @unittest.skipIf('NO_MOCK_SERVER' not in os.environ,
                   'The test is to ensure NO_MOCK_SERVER working correctly')
  @maybe_patch('bisect-builds.GetRevisionFromVersion', return_value=123)
  def test_no_mock(self, mock_GetRevisionFromVersion):
    self.assertEqual(bisect_builds.GetRevisionFromVersion('127.0.6533.74'),
                     1313161)
    mock_GetRevisionFromVersion.assert_called()

  @patch('bisect-builds.ArchiveBuild._install_revision')
  @patch('bisect-builds.ArchiveBuild._launch_revision',
         return_value=(1, '', ''))
  def test_run_revision_should_return_early(self, mock_launch_revision,
                                            mock_install_revision):
    build = self.create_build()
    build.run_revision('', [])
    mock_launch_revision.assert_called_once()

  @patch('bisect-builds.ArchiveBuild._install_revision')
  @patch('bisect-builds.ArchiveBuild._launch_revision',
         return_value=(0, '', ''))
  def test_run_revision_should_do_all_runs(self, mock_launch_revision,
                                           mock_install_revision):
    build = self.create_build(
        ['-a', 'linux64', '-g', '0', '-b', '9', '--time', '10'])
    build.run_revision('', [])
    self.assertEqual(mock_launch_revision.call_count, 10)

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('glob.glob', return_value=['temp-dir/linux64/chrome'])
  @patch('os.path.abspath', return_value='/tmp/temp-dir/linux64/chrome')
  def test_install_revision_should_unzip_and_search_executable(
      self, mock_abspath, mock_glob, mock_UnzipFilenameToDir):
    build = self.create_build()
    self.assertEqual(build._install_revision('some-file.zip', 'temp-dir'),
                     '/tmp/temp-dir/linux64/chrome')
    mock_UnzipFilenameToDir.assert_called_once_with('some-file.zip', 'temp-dir')
    mock_glob.assert_called_once_with('temp-dir/*/chrome')
    mock_abspath.assert_called_once_with('temp-dir/linux64/chrome')

  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_launch_revision_should_run_command(self, mock_Popen):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    build = self.create_build()
    build._launch_revision('temp-dir', 'temp-dir/linux64/chrome', [])
    mock_Popen.assert_called_once_with(
        ['temp-dir/linux64/chrome', '--user-data-dir=profile'],
        cwd='temp-dir',
        bufsize=-1,
        stdout=ANY,
        stderr=ANY)


class ReleaseBuildTest(BisectTestCase):

  def test_should_look_up_path_context(self):
    options, args = bisect_builds.ParseCommandLine(
        ['-r', '-a', 'linux64', '-g', '127.0.6533.74', '-b', '127.0.6533.88'])
    self.assertEqual(options.archive, 'linux64')
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    self.assertEqual(build.binary_name, 'chrome')
    self.assertEqual(build.listing_platform_dir, 'linux64/')
    self.assertEqual(build.archive_name, 'chrome-linux64.zip')
    self.assertEqual(build.archive_extract_dir, 'chrome-linux64')

  @maybe_patch(
      'bisect-builds.GsutilList',
      return_value=[
          'gs://chrome-unsigned/desktop-5c0tCh/%s/linux64/chrome-linux64.zip' %
          x for x in ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76']
      ])
  def test_get_rev_list(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine(
        ['-r', '-a', 'linux64', '-g', '127.0.6533.74', '-b', '127.0.6533.76'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    self.assertEqual(build.get_rev_list(),
                     ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76'])
    mock_GsutilList.assert_any_call('gs://chrome-unsigned/desktop-5c0tCh')
    mock_GsutilList.assert_any_call(*[
        'gs://chrome-unsigned/desktop-5c0tCh/%s/linux64/chrome-linux64.zip' % x
        for x in ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76']
    ],
                                    ignore_fail=True)
    self.assertEqual(mock_GsutilList.call_count, 2)

  @patch('bisect-builds.GsutilList',
         return_value=['127.0.6533.74', '127.0.6533.75', '127.0.6533.76'])
  def test_should_save_and_load_cache(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'linux64', '-g', '127.0.6533.74', '-b', '127.0.6533.77',
        '--use-local-cache'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    # Load the non-existent cache and write to it.
    cached_data = []
    write_mock = MagicMock()
    write_mock.__enter__().write.side_effect = lambda d: cached_data.append(d)
    with patch('builtins.open',
               side_effect=[FileNotFoundError, FileNotFoundError, write_mock]):
      self.assertEqual(build.get_rev_list(),
                       ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76'])
      mock_GsutilList.assert_called()
    cached_json = json.loads(''.join(cached_data))
    self.assertDictEqual(
        cached_json, {
            build._rev_list_cache_key:
            ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76']
        })
    # Load cache with cached data and merge with new data
    mock_GsutilList.return_value = ['127.0.6533.76', '127.0.6533.77']
    build = bisect_builds.create_archive_build(options)
    with patch('builtins.open', mock_open(read_data=''.join(cached_data))):
      self.assertEqual(
          build.get_rev_list(),
          ['127.0.6533.74', '127.0.6533.75', '127.0.6533.76', '127.0.6533.77'])
    print(mock_GsutilList.call_args)
    mock_GsutilList.assert_any_call(
        'gs://chrome-unsigned/desktop-5c0tCh'
        '/127.0.6533.76/linux64/chrome-linux64.zip',
        'gs://chrome-unsigned/desktop-5c0tCh'
        '/127.0.6533.77/linux64/chrome-linux64.zip',
        ignore_fail=True)


class ArchiveBuildWithCommitPositionTest(BisectTestCase):

  def setUp(self):
    patch.multiple(bisect_builds.ArchiveBuildWithCommitPosition,
                   __abstractmethods__=set(),
                   build_type='release').start()

  @maybe_patch('bisect-builds.GetRevisionFromVersion', return_value=1313161)
  @maybe_patch('bisect-builds.GetChromiumRevision', return_value=999999999)
  def test_should_convert_revision_as_commit_position(
      self, mock_GetChromiumRevision, mock_GetRevisionFromVersion):
    options, args = bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '-g', '127.0.6533.74'])
    build = bisect_builds.ArchiveBuildWithCommitPosition(options)
    self.assertEqual(build.good_revision, 1313161)
    self.assertEqual(build.bad_revision, 999999999)
    mock_GetRevisionFromVersion.assert_called_once_with('127.0.6533.74')
    mock_GetChromiumRevision.assert_called()


class OfficialBuildTest(BisectTestCase):

  def test_should_lookup_path_context(self):
    options, args = bisect_builds.ParseCommandLine(
        ['-o', '-a', 'linux64', '-g', '0', '-b', '10'])
    self.assertEqual(options.archive, 'linux64')
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.OfficialBuild)
    self.assertEqual(build.binary_name, 'chrome')
    self.assertEqual(build.listing_platform_dir, 'linux-builder-perf/')
    self.assertEqual(build.archive_name, 'chrome-perf-linux.zip')
    self.assertEqual(build.archive_extract_dir, 'full-build-linux')

  @maybe_patch('bisect-builds.GsutilList',
               return_value=[
                   'full-build-linux_%d.zip' % x
                   for x in range(1313161, 1313164)
               ])
  def test_get_rev_list(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine(
        ['-o', '-a', 'linux64', '-g', '1313161', '-b', '1313163'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.OfficialBuild)
    self.assertEqual(build.get_rev_list(), list(range(1313161, 1313164)))
    mock_GsutilList.assert_called_once_with(
        'gs://chrome-test-builds/official-by-commit/linux-builder-perf/')


class SnapshotBuildTest(BisectTestCase):

  def test_should_lookup_path_context(self):
    options, args = bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '-g', '0', '-b', '10'])
    self.assertEqual(options.archive, 'linux64')
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    self.assertEqual(build.binary_name, 'chrome')
    self.assertEqual(build.listing_platform_dir, 'Linux_x64/')
    self.assertEqual(build.archive_name, 'chrome-linux.zip')
    self.assertEqual(build.archive_extract_dir, 'chrome-linux')

  CommonDataXMLContent = '''<?xml version='1.0' encoding='UTF-8'?>
    <ListBucketResult xmlns='http://doc.s3.amazonaws.com/2006-03-01'>
      <Name>chromium-browser-snapshots</Name>
      <Prefix>Linux_x64/</Prefix>
      <Marker></Marker>
      <NextMarker></NextMarker>
      <Delimiter>/</Delimiter>
      <IsTruncated>true</IsTruncated>
      <CommonPrefixes>
        <Prefix>Linux_x64/1313161/</Prefix>
      </CommonPrefixes>
      <CommonPrefixes>
        <Prefix>Linux_x64/1313163/</Prefix>
      </CommonPrefixes>
      <CommonPrefixes>
        <Prefix>Linux_x64/1313185/</Prefix>
      </CommonPrefixes>
    </ListBucketResult>
  '''

  @maybe_patch('urllib.request.urlopen',
               return_value=io.StringIO(CommonDataXMLContent))
  @patch('bisect-builds.GetChromiumRevision', return_value=1313185)
  def test_get_rev_list(self, mock_GetChromiumRevision, mock_urlopen):
    options, args = bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '-g', '1313161', '-b', '1313185'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    rev_list = build.get_rev_list()
    mock_urlopen.assert_any_call(
        'http://commondatastorage.googleapis.com/chromium-browser-snapshots/'
        '?delimiter=/&prefix=Linux_x64/&marker=Linux_x64/1313161')
    self.assertEqual(mock_urlopen.call_count, 1)
    self.assertEqual(rev_list, [1313161, 1313163, 1313185])

  @patch('bisect-builds.SnapshotBuild._fetch_and_parse',
         return_value=([int(s)
                        for s in sorted([str(x) for x in range(1, 11)])], None))
  def test_get_rev_list_should_start_from_a_marker(self, mock_fetch_and_parse):
    options, args = bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '-g', '0', '-b', '9'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    rev_list = build._get_rev_list(0, 9)
    self.assertEqual(rev_list, list(range(1, 10)))
    mock_fetch_and_parse.assert_called_once_with(
        'http://commondatastorage.googleapis.com/chromium-browser-snapshots/'
        '?delimiter=/&prefix=Linux_x64/&marker=Linux_x64/0')
    mock_fetch_and_parse.reset_mock()
    rev_list = build._get_rev_list(1, 9)
    self.assertEqual(rev_list, list(range(1, 10)))
    mock_fetch_and_parse.assert_called_once_with(
        'http://commondatastorage.googleapis.com/chromium-browser-snapshots/'
        '?delimiter=/&prefix=Linux_x64/&marker=Linux_x64/1')

  @patch('bisect-builds.SnapshotBuild._fetch_and_parse',
         return_value=([int(s)
                        for s in sorted([str(x) for x in range(1, 11)])], None))
  def test_get_rev_list_should_scan_all_pages(self, mock_fetch_and_parse):
    options, args = bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '-g', '3', '-b', '11'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    rev_list = build._get_rev_list(0, 11)
    self.assertEqual(sorted(rev_list), list(range(1, 11)))
    mock_fetch_and_parse.assert_called_once_with(
        'http://commondatastorage.googleapis.com/chromium-browser-snapshots/'
        '?delimiter=/&prefix=Linux_x64/')


class ASANBuildTest(BisectTestCase):

  CommonDataXMLContent = '''<?xml version='1.0' encoding='UTF-8'?>
    <ListBucketResult xmlns='http://doc.s3.amazonaws.com/2006-03-01'>
      <Name>chromium-browser-asan</Name>
      <Prefix>mac-release/asan-mac-release</Prefix>
      <Marker></Marker>
      <NextMarker></NextMarker>
      <Delimiter>.zip</Delimiter>
      <IsTruncated>true</IsTruncated>
      <CommonPrefixes>
        <Prefix>mac-release/asan-mac-release-1313186.zip</Prefix>
      </CommonPrefixes>
      <CommonPrefixes>
        <Prefix>mac-release/asan-mac-release-1313195.zip</Prefix>
      </CommonPrefixes>
      <CommonPrefixes>
        <Prefix>mac-release/asan-mac-release-1313210.zip</Prefix>
      </CommonPrefixes>
    </ListBucketResult>
  '''

  @maybe_patch('urllib.request.urlopen',
               return_value=io.StringIO(CommonDataXMLContent))
  def test_get_rev_list(self, mock_urlopen):
    options, args = bisect_builds.ParseCommandLine(
        ['--asan', '-a', 'mac', '-g', '1313161', '-b', '1313210'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ASANBuild)
    rev_list = build.get_rev_list()
    # print(mock_urlopen.call_args_list)
    mock_urlopen.assert_any_call(
        'http://commondatastorage.googleapis.com/chromium-browser-asan/'
        '?delimiter=.zip&prefix=mac-release/asan-mac-release'
        '&marker=mac-release/asan-mac-release-1313161.zip')
    self.assertEqual(mock_urlopen.call_count, 1)
    self.assertEqual(rev_list, [1313186, 1313195, 1313210])


class AndroidBuildTest(BisectTestCase):

  def setUp(self):
    # patch for devil_imports
    self.patchers = []
    flag_changer_patcher = maybe_patch('bisect-builds.flag_changer',
                                       create=True)
    self.patchers.append(flag_changer_patcher)
    self.mock_flag_changer = flag_changer_patcher.start()
    chrome_patcher = maybe_patch('bisect-builds.chrome', create=True)
    self.patchers.append(chrome_patcher)
    self.mock_chrome = chrome_patcher.start()
    version_codes_patcher = maybe_patch('bisect-builds.version_codes',
                                        create=True)
    self.patchers.append(version_codes_patcher)
    self.mock_version_codes = version_codes_patcher.start()
    self.mock_version_codes.LOLLIPOP = 21
    self.mock_version_codes.NOUGAT = 24
    self.mock_version_codes.PIE = 28
    self.mock_version_codes.Q = 29
    initial_android_device_patcher = patch(
        'bisect-builds.InitializeAndroidDevice')
    self.patchers.append(initial_android_device_patcher)
    self.mock_initial_android_device = initial_android_device_patcher.start()
    self.device = self.mock_initial_android_device.return_value
    self.set_sdk_level(bisect_builds.version_codes.Q)

  def set_sdk_level(self, level):
    self.device.build_version_sdk = level

  def tearDown(self):
    for patcher in self.patchers:
      patcher.stop()


class AndroidReleaseBuildTest(AndroidBuildTest):

  def setUp(self):
    super().setUp()
    self.set_sdk_level(bisect_builds.version_codes.PIE)

  @maybe_patch(
      'bisect-builds.GsutilList',
      return_value=[
          'gs://chrome-signed/android-B0urB0N/%s/arm_64/MonochromeStable.apk' %
          x for x in ['127.0.6533.76', '127.0.6533.78', '127.0.6533.79']
      ])
  def test_get_android_rev_list(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64', '--apk', 'chrome_stable', '-g',
        '127.0.6533.76', '-b', '127.0.6533.79', '--signed'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    self.assertEqual(build.get_rev_list(),
                     ['127.0.6533.76', '127.0.6533.78', '127.0.6533.79'])
    mock_GsutilList.assert_any_call('gs://chrome-signed/android-B0urB0N')
    mock_GsutilList.assert_any_call(*[
        'gs://chrome-signed/android-B0urB0N/%s/arm_64/MonochromeStable.apk' % x
        for x in ['127.0.6533.76', '127.0.6533.78', '127.0.6533.79']
    ],
                                    ignore_fail=True)
    self.assertEqual(mock_GsutilList.call_count, 2)

  @patch('bisect-builds.InstallOnAndroid')
  def test_install_revision(self, mock_InstallOnAndroid):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64', '-g', '1313161', '-b', '1313210', '--apk',
        'chrome'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    build._install_revision('chrome.apk', 'temp-dir')
    mock_InstallOnAndroid.assert_called_once_with(self.device, 'chrome.apk')

  @patch('bisect-builds.LaunchOnAndroid')
  def test_launch_revision(self, mock_LaunchOnAndroid):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64', '-g', '1313161', '-b', '1313210', '--apk',
        'chrome'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    build._launch_revision('temp-dir', None)
    mock_LaunchOnAndroid.assert_called_once_with(self.device, 'chrome')


class AndroidSnapshotBuildTest(AndroidBuildTest):

  def setUp(self):
    super().setUp()
    self.set_sdk_level(bisect_builds.version_codes.PIE)

  @patch('bisect-builds.InstallOnAndroid')
  @patch('bisect-builds.ArchiveBuild._install_revision',
         return_value='chrome.apk')
  def test_install_revision(self, mock_install_revision, mock_InstallOnAndroid):
    options, args = bisect_builds.ParseCommandLine([
        '-a', 'android-arm64', '-g', '1313161', '-b', '1313210', '--apk',
        'chrome'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidSnapshotBuild)
    build._install_revision('chrome.zip', 'temp-dir')
    mock_install_revision.assert_called_once_with('chrome.zip', 'temp-dir')
    mock_InstallOnAndroid.assert_called_once_with(self.device, 'chrome.apk')


class AndroidTrichromeReleaseBuildTest(AndroidBuildTest):

  def setUp(self):
    super().setUp()
    self.set_sdk_level(bisect_builds.version_codes.Q)

  @maybe_patch(
      'bisect-builds.GsutilList',
      side_effect=[[
          'gs://chrome-unsigned/android-B0urB0N/%s/' % x for x in [
              '129.0.6626.0', '129.0.6626.1', '129.0.6627.0', '129.0.6627.1',
              '129.0.6628.0', '129.0.6628.1'
          ]
      ],
                   [('gs://chrome-unsigned/android-B0urB0N/%s/'
                     'high-arm_64/TrichromeChromeGoogle6432Stable.apks') % x
                    for x in ['129.0.6626.0', '129.0.6627.0', '129.0.6628.0']]])
  def test_get_rev_list(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64-high', '--apk', 'chrome_stable', '-g',
        '129.0.6626.0', '-b', '129.0.6628.0'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeReleaseBuild)
    self.assertEqual(build.get_rev_list(),
                     ['129.0.6626.0', '129.0.6627.0', '129.0.6628.0'])
    print(mock_GsutilList.call_args_list)
    mock_GsutilList.assert_any_call('gs://chrome-unsigned/android-B0urB0N')
    mock_GsutilList.assert_any_call(*[
        ('gs://chrome-unsigned/android-B0urB0N/%s/'
         'high-arm_64/TrichromeChromeGoogle6432Stable.apks') % x for x in [
             '129.0.6626.0', '129.0.6626.1', '129.0.6627.0', '129.0.6627.1',
             '129.0.6628.0'
         ]
    ],
                                    ignore_fail=True)
    self.assertEqual(mock_GsutilList.call_count, 2)

  def test_should_raise_exception_for_PIE(self):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64-high', '--apk', 'chrome_stable', '-g',
        '129.0.6626.0', '-b', '129.0.6667.0'
    ])
    self.set_sdk_level(bisect_builds.version_codes.PIE)
    with self.assertRaises(bisect_builds.BisectException):
      bisect_builds.create_archive_build(options)

  def test_get_download_url(self):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64-high', '--apk', 'chrome_stable', '-g',
        '129.0.6626.0', '-b', '129.0.6628.0'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeReleaseBuild)
    download_urls = build.get_download_url('129.0.6626.0')
    self.maxDiff = 1000
    self.assertDictEqual(
        download_urls, {
            'trichrome':
            ('gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/high-arm_64/'
             'TrichromeChromeGoogle6432Stable.apks'),
            'trichrome_library':
            ('gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/high-arm_64/'
             'TrichromeLibraryGoogle6432Stable.apk'),
        })

  @patch('bisect-builds.InstallOnAndroid')
  def test_install_revision(self, mock_InstallOnAndroid):
    downloads = {
        'trichrome': 'some-file.apks',
        'trichrome_library': 'file2.apk',
    }
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'android-arm64-high', '--apk', 'chrome_stable', '-g',
        '129.0.6626.0', '-b', '129.0.6628.0'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeReleaseBuild)
    build._install_revision(downloads, 'tmp-dir')
    mock_InstallOnAndroid.assert_any_call(self.device, 'some-file.apks')
    mock_InstallOnAndroid.assert_any_call(self.device, 'file2.apk')


class AndroidTrichromeOfficialBuildTest(AndroidBuildTest):

  @maybe_patch('bisect-builds.GsutilList',
               return_value=[
                   'full-build-linux_%d.zip' % x
                   for x in [1334339, 1334342, 1334344, 1334345, 1334356]
               ])
  def test_get_rev_list(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine([
        '-o', '-a', 'android-arm64-high', '--apk', 'chrome', '-g', '1334338',
        '-b', '1334380'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeOfficialBuild)
    self.assertEqual(build.get_rev_list(),
                     [1334339, 1334342, 1334344, 1334345, 1334356])
    mock_GsutilList.assert_called_once_with(
        'gs://chrome-test-builds/official-by-commit/'
        'android_arm64_high_end-builder-perf/')

  def test_get_download_url(self):
    options, args = bisect_builds.ParseCommandLine([
        '-o', '-a', 'android-arm64-high', '--apk', 'chrome', '-g', '1334338',
        '-b', '1334380'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeOfficialBuild)
    self.assertEqual(
        build.get_download_url(1334338),
        'gs://chrome-test-builds/official-by-commit'
        '/android_arm64_high_end-builder-perf/full-build-linux_1334338.zip')

  @patch('glob.glob',
         side_effect=[[
             'temp-dir/full-build-linux/apks/TrichromeChromeGoogle6432.apks'
         ], ['temp-dir/full-build-linux/apks/TrichromeLibraryGoogle6432.apk']])
  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('bisect-builds.InstallOnAndroid')
  def test_install_revision(self, mock_InstallOnAndroid,
                            mock_UnzipFilenameToDir, mock_glob):
    options, args = bisect_builds.ParseCommandLine([
        '-o', '-a', 'android-arm64-high', '--apk', 'chrome', '-g', '1334338',
        '-b', '1334380'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeOfficialBuild)
    build._install_revision('download.zip', 'tmp-dir')
    mock_UnzipFilenameToDir.assert_called_once_with('download.zip', 'tmp-dir')
    mock_InstallOnAndroid.assert_any_call(
        self.device,
        'temp-dir/full-build-linux/apks/TrichromeLibraryGoogle6432.apk')
    mock_InstallOnAndroid.assert_any_call(
        self.device,
        'temp-dir/full-build-linux/apks/TrichromeChromeGoogle6432.apks')

  @unittest.skipUnless('NO_MOCK_SERVER' in os.environ,
                       'The test only valid when NO_MOCK_SERVER')
  @patch('bisect-builds.InstallOnAndroid')
  @patch('bisect-builds.LaunchOnAndroid')
  def test_run_revision_with_real_zipfile(self, mock_LaunchOnAndroid,
                                          mock_InstallOnAndroid):
    options, args = bisect_builds.ParseCommandLine([
        '-o', '-a', 'android-arm64-high', '--apk', 'chrome', '-g', '1334338',
        '-b', '1334380'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidTrichromeOfficialBuild)
    download_job = build.get_download_job(1334339)
    zip_file = download_job.start().wait_for()
    build.run_revision(zip_file, [])
    print(mock_InstallOnAndroid.call_args_list)
    self.assertRegex(mock_InstallOnAndroid.mock_calls[0].args[1],
                     'full-build-linux/apks/TrichromeLibraryGoogle6432.apk$')
    self.assertRegex(
        mock_InstallOnAndroid.mock_calls[1].args[1],
        'full-build-linux/apks/TrichromeChromeGoogle6432.minimal.apks$')
    mock_LaunchOnAndroid.assert_called_once_with(self.device, 'chrome')


class LinuxReleaseBuildTest(BisectTestCase):

  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_launch_revision_should_has_no_sandbox(self, mock_Popen):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    options, args = bisect_builds.ParseCommandLine(
        ['-r', '-a', 'linux64', '-g', '127.0.6533.74', '-b', '127.0.6533.88'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.LinuxReleaseBuild)
    build._launch_revision('temp-dir', 'temp-dir/linux64/chrome', [])
    mock_Popen.assert_called_once_with(
        ['temp-dir/linux64/chrome', '--user-data-dir=profile', '--no-sandbox'],
        cwd='temp-dir',
        bufsize=-1,
        stdout=ANY,
        stderr=ANY)


class IOSReleaseBuildTest(BisectTestCase):

  @maybe_patch(
      'bisect-builds.GsutilList',
      side_effect=[[
          'gs://chrome-unsigned/ios-G1N/127.0.6533.76/',
          'gs://chrome-unsigned/ios-G1N/127.0.6533.77/',
          'gs://chrome-unsigned/ios-G1N/127.0.6533.78/'
      ],
                   [
                       'gs://chrome-unsigned/ios-G1N'
                       '/127.0.6533.76/iphoneos17.5/ios/10863/canary.ipa',
                       'gs://chrome-unsigned/ios-G1N'
                       '/127.0.6533.77/iphoneos17.5/ios/10866/canary.ipa',
                       'gs://chrome-unsigned/ios-G1N'
                       '/127.0.6533.78/iphoneos17.5/ios/10868/canary.ipa'
                   ]])
  def test_list_rev(self, mock_GsutilList):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'ios', '--ipa=canary.ipa', '--device-id', '321', '-g',
        '127.0.6533.74', '-b', '127.0.6533.78'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSReleaseBuild)
    self.assertEqual(build.get_rev_list(),
                     ['127.0.6533.76', '127.0.6533.77', '127.0.6533.78'])
    mock_GsutilList.assert_any_call('gs://chrome-unsigned/ios-G1N')
    mock_GsutilList.assert_any_call(*[
        'gs://chrome-unsigned/ios-G1N/%s/*/ios/*/canary.ipa' % x
        for x in ['127.0.6533.76', '127.0.6533.77', '127.0.6533.78']
    ],
                                    ignore_fail=True)

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('glob.glob', return_value=['Payload/canary.app/Info.plist'])
  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_install_revision(self, mock_Popen, mock_glob,
                            mock_UnzipFilenameToDir):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'ios', '--ipa=canary.ipa', '--device-id', '321', '-g',
        '127.0.6533.74', '-b', '127.0.6533.78'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSReleaseBuild)
    build._install_revision('canary.ipa', 'tempdir')
    mock_glob.assert_called_once_with('tempdir/Payload/*/Info.plist')
    mock_Popen.assert_has_calls([
        call([
            'xcrun', 'devicectl', 'device', 'install', 'app', '--device', '321',
            'canary.ipa'
        ],
             cwd=None,
             bufsize=-1,
             stdout=-1,
             stderr=-1),
        call([
            'plutil', '-extract', 'CFBundleIdentifier', 'raw',
            'Payload/canary.app/Info.plist'
        ],
             cwd=None,
             bufsize=-1,
             stdout=-1,
             stderr=-1)
    ],
                                any_order=True)

  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_launch_revision(self, mock_Popen):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'ios', '--ipa=canary.ipa', '--device-id', '321', '-g',
        '127.0.6533.74', '-b', '127.0.6533.78'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSReleaseBuild)
    build._launch_revision('tempdir', 'com.google.chrome.ios',
                           ['args1', 'args2'])
    mock_Popen.assert_any_call([
        'xcrun', 'devicectl', 'device', 'process', 'launch', '--device', '321',
        'com.google.chrome.ios', 'args1', 'args2'
    ],
                               cwd=None,
                               bufsize=-1,
                               stdout=-1,
                               stderr=-1)

  @unittest.skipUnless('NO_MOCK_SERVER' in os.environ,
                       'The test only valid when NO_MOCK_SERVER')
  @patch('bisect-builds.IOSReleaseBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision(self, mock_run):
    options, args = bisect_builds.ParseCommandLine([
        '-r', '-a', 'ios', '--ipa=canary.ipa', '--device-id', '321', '-g',
        '127.0.6533.74', '-b', '127.0.6533.78'
    ])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSReleaseBuild)
    job = build.get_download_job('127.0.6533.76')
    ipa = job.start().wait_for()
    build.run_revision(ipa, args)
    mock_run.assert_has_calls([
        call([
            'xcrun', 'devicectl', 'device', 'install', 'app', '--device', '321',
            ANY
        ]),
        call(['plutil', '-extract', 'CFBundleIdentifier', 'raw', ANY]),
        call([
            'xcrun', 'devicectl', 'device', 'process', 'launch', '--device',
            '321', 'stdout'
        ])
    ])


if __name__ == '__main__':
  unittest.main()