#!/usr/bin/env vpython3
# 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 base64
import functools
import io
import json
import os
import re
import subprocess
import sys
import tempfile
import unittest
import urllib.request
from unittest.mock import (
  ANY,
  Mock,
  MagicMock,
  mock_open,
  patch,
  call,
  PropertyMock,
)

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

    # patch cache filename to prevent pollute working dir.
    fd, cls.tmp_cache_file = tempfile.mkstemp(suffix='.json')
    os.close(fd)
    cls.cache_filename_patcher = patch(
      'bisect-builds.ArchiveBuild._rev_list_cache_filename',
      new=PropertyMock(return_value=cls.tmp_cache_file),
    )
    cls.cache_filename_patcher.start()

  @classmethod
  def tearDownClass(cls):
    if sys.version_info[:2] > (3, 8):
      cls.name_pattern_patcher.stop()
    cls.cache_filename_patcher.stop()
    os.unlink(cls.tmp_cache_file)


class BisectTest(BisectTestCase):
  max_rev = 10000

  def setUp(self):
    self.patchers = []
    self.patchers.append(patch('bisect-builds.DownloadJob._fetch'))
    self.patchers.append(
      patch('bisect-builds.ArchiveBuild.run_revision', return_value=(0, '', ''))
    )
    self.patchers.append(
      patch(
        'bisect-builds.SnapshotBuild._get_rev_list',
        return_value=range(self.max_rev),
      )
    )
    for each in self.patchers:
      each.start()

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

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

  @patch('builtins.print')
  def testBisectConsistentAnswer(self, mock_print):

    def get_steps():
      steps = []
      for call in mock_print.call_args_list:
        if call.args and call.args[0].startswith('You have'):
          steps.append(int(re.search(r'(\d+) steps', call.args[0])[1]))
      return steps

    self.assertEqual(self.bisect(1000, 100, lambda *args: 'g'), (100, 101))
    self.assertSequenceEqual(get_steps(), range(10, 1, -1))

    mock_print.reset_mock()
    self.assertEqual(self.bisect(100, 1000, lambda *args: 'b'), (100, 101))
    self.assertSequenceEqual(get_steps(), range(10, 0, -1))

    mock_print.reset_mock()
    self.assertEqual(self.bisect(2000, 200, lambda *args: 'b'), (1999, 2000))
    self.assertSequenceEqual(get_steps(), range(11, 0, -1))

    mock_print.reset_mock()
    self.assertEqual(self.bisect(200, 2000, lambda *args: 'g'), (1999, 2000))
    self.assertSequenceEqual(get_steps(), range(11, 1, -1))

  @patch('bisect-builds.ArchiveBuild.run_revision', return_value=(0, '', ''))
  def test_bisect_should_retry(self, mock_run_revision):
    evaluator = Mock(side_effect='rgrgrbr')
    self.assertEqual(self.bisect(9, 1, evaluator), (2, 3))
    tested_revisions = [c.args[0] for c in evaluator.call_args_list]
    self.assertEqual(tested_revisions, [5, 5, 3, 3, 2, 2])
    self.assertEqual(mock_run_revision.call_count, 6)

    evaluator = Mock(side_effect='rgrrrgrbr')
    self.assertEqual(self.bisect(1, 10, evaluator), (8, 9))
    tested_revisions = [c.args[0] for c in evaluator.call_args_list]
    self.assertEqual(tested_revisions, [6, 6, 8, 8, 8, 8, 9, 9])

  def test_bisect_should_unknown(self):
    evaluator = Mock(side_effect='uuuggggg')
    self.assertEqual(self.bisect(9, 1, evaluator), (1, 2))
    tested_revisions = [c.args[0] for c in evaluator.call_args_list]
    self.assertEqual(tested_revisions, [5, 3, 6, 7, 2])

    evaluator = Mock(side_effect='uuugggggg')
    self.assertEqual(self.bisect(1, 9, evaluator), (8, 9))
    tested_revisions = [c.args[0] for c in evaluator.call_args_list]
    self.assertEqual(tested_revisions, [5, 7, 4, 3, 8])

  def test_bisect_should_quit(self):
    evaluator = Mock(side_effect=SystemExit())
    with self.assertRaises(SystemExit):
      self.assertEqual(self.bisect(9, 1, evaluator), (None, None))

  def test_edge_cases(self):
    with self.assertRaises(bisect_builds.BisectException):
      self.assertEqual(self.bisect(1, 1, Mock()), (1, 1))
    self.assertEqual(self.bisect(2, 1, Mock()), (1, 2))
    self.assertEqual(self.bisect(1, 2, Mock()), (1, 2))


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)
    mock_unlink.assert_has_calls(
      [call('some-file.apks'), call('file2.apk')], any_order=True
    )
    self.assertEqual(mock_gsutil.call_count, 2)

  @patch(
    "urllib.request.urlopen",
    side_effect=urllib.request.HTTPError('url', 404, 'Not Found', None, None),
  )
  @patch('subprocess.Popen', spec=subprocess.Popen)
  @patch('bisect-builds.GSUTILS_PATH', new='/some/path')
  def test_download_failure_should_raised(self, mock_Popen, mock_urlopen):
    fetch = bisect_builds.DownloadJob('http://some-file.zip', 123)
    with self.assertRaises(urllib.request.HTTPError):
      fetch.start().wait_for()

    mock_Popen.return_value.communicate.return_value = (b'', b'status=403')
    mock_Popen.return_value.returncode = 1
    fetch = bisect_builds.DownloadJob('gs://some-file.zip', 123)
    with self.assertRaises(bisect_builds.BisectException):
      fetch.start().wait_for()


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):
    args = ['-a', 'linux64', '-g', '0', '-b', '9', *args]
    options = bisect_builds.ParseCommandLine(args)
    return bisect_builds.ArchiveBuild(options)

  def test_cache_should_not_work_if_not_enabled(self):
    build = self.create_build('--no-local-cache')
    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()
    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('--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('--no-local-cache')
    # 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('--no-local-cache')
    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('--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'),
      {'chrome': '/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('bisect-builds.UnzipFilenameToDir')
  @patch(
    'glob.glob',
    side_effect=[
      ['temp-dir/chrome-linux64/chrome'],
      ['temp-dir/chromedriver_linux64/chromedriver'],
    ],
  )
  @patch(
    'os.path.abspath',
    side_effect=[
      '/tmp/temp-dir/chrome-linux64/chrome',
      '/tmp/temp-dir/chromedriver_linux64/chromedriver',
    ],
  )
  def test_install_chromedriver(
    self, mock_abspath, mock_glob, mock_UnzipFilenameToDir
  ):
    build = self.create_build('--chromedriver')
    self.assertEqual(
      build._install_revision(
        {
          'chrome': 'some-file.zip',
          'chromedriver': 'some-other-file.zip',
        },
        'temp-dir',
      ),
      {
        'chrome': '/tmp/temp-dir/chrome-linux64/chrome',
        'chromedriver': '/tmp/temp-dir/chromedriver_linux64/chromedriver',
      },
    )
    mock_UnzipFilenameToDir.assert_has_calls(
      [
        call('some-file.zip', 'temp-dir'),
        call('some-other-file.zip', 'temp-dir'),
      ]
    )
    mock_glob.assert_has_calls(
      [
        call('temp-dir/*/chrome'),
        call('temp-dir/*/chromedriver'),
      ]
    )
    mock_abspath.assert_has_calls(
      [
        call('temp-dir/chrome-linux64/chrome'),
        call('temp-dir/chromedriver_linux64/chromedriver'),
      ]
    )

  @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', {'chrome': 'temp-dir/linux64/chrome'}, []
    )
    mock_Popen.assert_called_once_with(
      'temp-dir/linux64/chrome --user-data-dir=temp-dir/profile',
      cwd=None,
      shell=True,
      bufsize=-1,
      stdout=ANY,
      stderr=ANY,
    )

  @unittest.skipIf(sys.platform.startswith('win'), 'This test is not for win')
  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_launch_revision_should_run_command_for_mac(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',
      {
        'chrome': 'temp-dir/full-build-mac/'
        'Google Chrome.app/Contents/MacOS/Google Chrome'
      },
      [],
    )
    mock_Popen.assert_called_once_with(
      "'temp-dir/full-build-mac/"
      "Google Chrome.app/Contents/MacOS/Google Chrome'"
      ' --user-data-dir=temp-dir/profile',
      cwd=None,
      shell=True,
      bufsize=-1,
      stdout=ANY,
      stderr=ANY,
    )

  @unittest.skipUnless(
    sys.platform.startswith('win'), 'This test is for win only'
  )
  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_launch_revision_should_run_command_for_win(self, mock_Popen):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    build = self.create_build()
    build._launch_revision(
      'C:\\temp-dir', {'chrome': 'C:\\temp-dir\\full-build-win\\chrome.exe'}, []
    )
    mock_Popen.assert_called_once_with(
      "C:\\temp-dir\\full-build-win\\chrome.exe "
      '--user-data-dir=C:\\temp-dir/profile',
      cwd=None,
      shell=True,
      bufsize=-1,
      stdout=ANY,
      stderr=ANY,
    )

  @patch('subprocess.Popen', spec=subprocess.Popen)
  def test_command_replacement(self, mock_Popen):
    mock_Popen.return_value.communicate.return_value = ('', '')
    mock_Popen.return_value.returncode = 0
    build = self.create_build(
      '--chromedriver',
      '-c',
      'CHROMEDRIVER=%d BROWSER_EXECUTABLE_PATH=%p pytest %a',
    )
    build._launch_revision(
      '/tmp',
      {'chrome': '/tmp/chrome', 'chromedriver': '/tmp/chromedriver'},
      ['--args', '--args2="word 1"', 'word 2'],
    )
    if sys.platform.startswith('win'):
      mock_Popen.assert_called_once_with(
        'CHROMEDRIVER=/tmp/chromedriver BROWSER_EXECUTABLE_PATH=/tmp/chrome '
        'pytest --user-data-dir=/tmp/profile --args "--args2=\\"word 1\\"" '
        '"word 2"',
        cwd=None,
        shell=True,
        bufsize=-1,
        stdout=ANY,
        stderr=ANY,
      )
    else:
      mock_Popen.assert_called_once_with(
        'CHROMEDRIVER=/tmp/chromedriver BROWSER_EXECUTABLE_PATH=/tmp/chrome '
        'pytest --user-data-dir=/tmp/profile --args \'--args2="word 1"\' '
        '\'word 2\'',
        cwd=None,
        shell=True,
        bufsize=-1,
        stdout=ANY,
        stderr=ANY,
      )


class ReleaseBuildTest(BisectTestCase):
  def test_should_look_up_path_context(self):
    options = 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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'linux64',
        '-g',
        '127.0.6533.74',
        '-b',
        '127.0.6533.76',
        '--no-local-cache',
      ]
    )
    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 = 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,
    )

  def test_get_download_url(self):
    options = bisect_builds.ParseCommandLine(
      ['-r', '-a', 'linux64', '-g', '127.0.6533.74', '-b', '127.0.6533.77']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    download_urls = build.get_download_url('127.0.6533.74')
    self.assertEqual(
      download_urls,
      'gs://chrome-unsigned/desktop-5c0tCh'
      '/127.0.6533.74/linux64/chrome-linux64.zip',
    )

    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'linux64',
        '-g',
        '127.0.6533.74',
        '-b',
        '127.0.6533.77',
        '--chromedriver',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    download_urls = build.get_download_url('127.0.6533.74')
    self.assertEqual(
      download_urls,
      {
        'chrome': 'gs://chrome-unsigned/desktop-5c0tCh'
        '/127.0.6533.74/linux64/chrome-linux64.zip',
        'chromedriver': 'gs://chrome-unsigned/desktop-5c0tCh'
        '/127.0.6533.74/linux64/chromedriver_linux64.zip',
      },
    )

  @unittest.skipUnless(
    'NO_MOCK_SERVER' in os.environ, 'The test only valid when NO_MOCK_SERVER'
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision_with_real_zipfile(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'linux64',
        '-g',
        '127.0.6533.74',
        '-b',
        '127.0.6533.77',
        '--chromedriver',
        '-c',
        'driver=%d prog=%p',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ReleaseBuild)
    download_job = build.get_download_job('127.0.6533.74')
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    self.assertRegex(
      mock_run.call_args.args[0], r'driver=.+/chromedriver prog=.+/chrome'
    )


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 = 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 = 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 = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'linux64',
        '-g',
        '1313161',
        '-b',
        '1313163',
        '--no-local-cache',
      ]
    )
    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/'
    )

  @unittest.skipUnless(
    'NO_MOCK_SERVER' in os.environ, 'The test only valid when NO_MOCK_SERVER'
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision_with_real_zipfile(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'linux64',
        '-g',
        '1313161',
        '-b',
        '1313163',
        '--chromedriver',
        '-c',
        'driver=%d prog=%p',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.OfficialBuild)
    download_job = build.get_download_job(1334339)
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    self.assertRegex(
      mock_run.call_args.args[0], r'driver=.+/chromedriver prog=.+/chrome'
    )


class SnapshotBuildTest(BisectTestCase):
  def test_should_lookup_path_context(self):
    options = 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.BytesIO(CommonDataXMLContent.encode('utf8')),
  )
  @patch('bisect-builds.GetChromiumRevision', return_value=1313185)
  def test_get_rev_list(self, mock_GetChromiumRevision, mock_urlopen):
    options = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '1313161', '-b', '1313185', '--no-local-cache']
    )
    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 = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '0', '-b', '9', '--no-local-cache']
    )
    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 = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '3', '-b', '11', '--no-local-cache']
    )
    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/'
    )

  def test_get_download_url(self):
    options = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '3', '-b', '11']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    download_urls = build.get_download_url(123)
    self.assertEqual(
      download_urls,
      'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
      '/Linux_x64/123/chrome-linux.zip',
    )

    options = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '3', '-b', '11', '--chromedriver']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    download_urls = build.get_download_url(123)
    self.assertDictEqual(
      download_urls,
      {
        'chrome': 'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
        '/Linux_x64/123/chrome-linux.zip',
        'chromedriver': 'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
        '/Linux_x64/123/chromedriver_linux64.zip',
      },
    )

  @unittest.skipUnless(
    'NO_MOCK_SERVER' in os.environ, 'The test only valid when NO_MOCK_SERVER'
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision_with_real_zipfile(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '-a',
        'linux64',
        '-g',
        '1313161',
        '-b',
        '1313185',
        '--chromedriver',
        '-c',
        'driver=%d prog=%p',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    download_job = build.get_download_job(1313161)
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    self.assertRegex(
      mock_run.call_args.args[0], r'driver=.+/chromedriver prog=.+/chrome'
    )

  @patch('bisect-builds.GetChromiumRevision', return_value=1313185)
  def test_get_bad_revision(self, mock_GetChromiumRevision):
    options = bisect_builds.ParseCommandLine(['-a', 'linux64', '-g', '1313161'])
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    mock_GetChromiumRevision.assert_called_once_with(
      'http://commondatastorage.googleapis.com/chromium-browser-snapshots'
      '/Linux_x64/LAST_CHANGE'
    )
    self.assertEqual(build.bad_revision, 1313185)


class ChromeForTestingBuild(BisectTestCase):
  def test_should_lookup_path_context(self):
    options = bisect_builds.ParseCommandLine(
      ['-cft', '-a', 'linux64', '-g', '0', '-b', '10']
    )
    self.assertEqual(options.archive, 'linux64')
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    self.assertEqual(build.binary_name, 'chrome')
    self.assertEqual(build.listing_platform_dir, 'linux64/')
    self.assertEqual(build.archive_name, 'chrome-linux64.zip')
    self.assertEqual(build.chromedriver_binary_name, 'chromedriver')
    self.assertEqual(
      build.chromedriver_archive_name, 'chromedriver-linux64.zip'
    )

  CommonDataXMLContent = '''<?xml version='1.0' encoding='UTF-8'?>
  <ListBucketResult xmlns="http://doc.s3.amazonaws.com/2006-03-01">
    <Name>chrome-for-testing-per-commit-public</Name>
    <Prefix>linux64/</Prefix>
    <Marker/>
    <Delimiter>/</Delimiter>
    <IsTruncated>false</IsTruncated>
    <Contents>
      <Key>linux64/LAST_CHANGE</Key>
      <Generation>1733959087133532</Generation>
      <MetaGeneration>1</MetaGeneration>
      <LastModified>2024-12-11T23:18:07.235Z</LastModified>
      <ETag>"dd60cb93e225ab33d7254beca56b507a"</ETag>
      <Size>7</Size>
    </Contents>
    <CommonPrefixes>
      <Prefix>linux64/r1390729/</Prefix>
    </CommonPrefixes>
    <CommonPrefixes>
      <Prefix>linux64/r1390746/</Prefix>
    </CommonPrefixes>
    <CommonPrefixes>
      <Prefix>linux64/r1390757/</Prefix>
    </CommonPrefixes>
  </ListBucketResult>
  '''

  @maybe_patch(
    'urllib.request.urlopen',
    return_value=io.BytesIO(CommonDataXMLContent.encode('utf8')),
  )
  @patch('bisect-builds.GetChromiumRevision', return_value=1390757)
  def test_get_rev_list(self, mock_GetChromiumRevision, mock_urlopen):
    options = bisect_builds.ParseCommandLine(
      [
        '-cft',
        '-a',
        'linux64',
        '-g',
        '1390729',
        '-b',
        '1390757',
        '--no-local-cache',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    rev_list = build.get_rev_list()
    mock_urlopen.assert_any_call(
      'https://storage.googleapis.com/chrome-for-testing-per-commit-public/'
      '?delimiter=/&prefix=linux64/&marker=linux64/r1390729'
    )
    self.assertEqual(mock_urlopen.call_count, 1)
    self.assertEqual(rev_list, [1390729, 1390746, 1390757])

  def test_get_marker(self):
    options = bisect_builds.ParseCommandLine(
      ['-cft', '-a', 'linux64', '-g', '1', '-b', '3']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    self.assertEqual(
      'linux64/r1390729', build._get_marker_for_revision(1390729)
    )

  def test_get_download_url(self):
    options = bisect_builds.ParseCommandLine(
      ['-cft', '-a', 'linux64', '-g', '3', '-b', '11']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    download_urls = build.get_download_url(123)
    self.assertEqual(
      download_urls,
      'https://storage.googleapis.com/chrome-for-testing-per-commit-public'
      '/linux64/r123/chrome-linux64.zip',
    )

    options = bisect_builds.ParseCommandLine(
      ['-cft', '-a', 'linux64', '-g', '3', '-b', '11', '--chromedriver']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    download_urls = build.get_download_url(123)
    download_urls = build.get_download_url(123)
    self.assertEqual(
      download_urls,
      {
        'chrome': 'https://storage.googleapis.com/chrome-for-testing-per-commit-public'
        '/linux64/r123/chrome-linux64.zip',
        'chromedriver': 'https://storage.googleapis.com/chrome-for-testing-per-commit-public'
        '/linux64/r123/chromedriver-linux64.zip',
      },
    )

  @patch('bisect-builds.GetChromiumRevision', return_value=1390757)
  def test_get_bad_revision(self, mock_GetChromiumRevision):
    options = bisect_builds.ParseCommandLine(
      ['-cft', '-a', 'linux64', '-g', '3']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.ChromeForTestingBuild)
    mock_GetChromiumRevision.assert_called_once_with(
      'https://storage.googleapis.com/chrome-for-testing-per-commit-public'
      '/linux64/LAST_CHANGE'
    )
    self.assertEqual(build.bad_revision, 1390757)

  @unittest.skipUnless(
    'NO_MOCK_SERVER' in os.environ, 'The test only valid when NO_MOCK_SERVER'
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision_with_real_zipfile(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '--cft',
        '-a',
        'linux64',
        '-g',
        '1',
        '--chromedriver',
        '-c',
        'driver=%d prog=%p',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.SnapshotBuild)
    download_job = build.get_download_job(build.bad_revision)
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    self.assertRegex(
      mock_run.call_args.args[0], r'driver=.+/chromedriver prog=.+/chrome'
    )


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.BytesIO(CommonDataXMLContent.encode('utf8')),
  )
  def test_get_rev_list(self, mock_urlopen):
    options = bisect_builds.ParseCommandLine(
      [
        '--asan',
        '-a',
        'mac',
        '-g',
        '1313161',
        '-b',
        '1313210',
        '--no-local-cache',
      ]
    )
    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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '--apk',
        'chrome_stable',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--signed',
        '--no-local-cache',
      ]
    )
    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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--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')

  @patch('bisect-builds.LaunchOnAndroid')
  def test_webview_launch_revision(self, mock_LaunchOnAndroid):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--apk',
        'webview',
      ]
    )
    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, 'webview')
    with self.assertRaises(bisect_builds.BisectException):
      build._launch_revision('temp-dir', None, ['args'])

  def test_get_apk_filename_webview(self):
    """Test _get_apk_filename returns AndroidWebview.apk for webview APK."""
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--apk',
        'webview',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    self.assertEqual(build._get_apk_filename(), 'AndroidWebview.apk')

  def test_get_apk_filename_chrome(self):
    """Test _get_apk_filename returns correct filename for chrome APK."""
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64',
        '-g',
        '127.0.6533.76',
        '-b',
        '127.0.6533.79',
        '--apk',
        'chrome',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    self.assertEqual(build._get_apk_filename(), 'Monochrome.apk')


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

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('bisect-builds.InstallOnAndroid')
  @patch('glob.glob', return_value=['Monochrome.apk'])
  def test_install_revision(self, mock_glob, mock_InstallOnAndroid, mock_unzip):
    options = bisect_builds.ParseCommandLine(
      ['-a', 'android-arm', '-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_glob.assert_called_once_with('temp-dir/*/apks/Monochrome.apk')
    mock_InstallOnAndroid.assert_called_once_with(self.device, 'Monochrome.apk')

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('sys.stdout', new_callable=io.StringIO)
  @patch(
    'glob.glob',
    side_effect=[
      [],
      [
        "temp-dir/full-build-linux/apks/MonochromeBeta.apk",
        "temp-dir/full-build-linux/apks/ChromePublic.apk",
      ],
    ],
  )
  def test_install_revision_with_show_available_apks(
    self, mock_glob, mock_stdout, mock_unzip
  ):
    options = bisect_builds.ParseCommandLine(
      ['-a', 'android-arm', '-g', '1313161', '-b', '1313210', '--apk', 'chrome']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidSnapshotBuild)
    with self.assertRaises(bisect_builds.BisectException):
      build._install_revision('chrome.zip', 'temp-dir')
    self.assertIn("The list of available --apk:", mock_stdout.getvalue())
    self.assertIn("chrome_beta", mock_stdout.getvalue())
    self.assertIn("chromium", mock_stdout.getvalue())

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('sys.stdout', new_callable=io.StringIO)
  @patch(
    'glob.glob',
    side_effect=[[], ["temp-dir/full-build-linux/apks/unknown.apks"]],
  )
  def test_install_revision_with_show_unknown_apks(
    self, mock_glob, mock_stdout, mock_unzip
  ):
    options = bisect_builds.ParseCommandLine(
      ['-a', 'android-arm', '-g', '1313161', '-b', '1313210', '--apk', 'chrome']
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidSnapshotBuild)
    with self.assertRaises(bisect_builds.BisectException):
      build._install_revision('chrome.zip', 'temp-dir')
    self.assertIn(
      "No supported apk found. But found following", mock_stdout.getvalue()
    )
    self.assertIn("unknown.apks", mock_stdout.getvalue())


class AndroidArm64HighReleaseBuildTest(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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-arm64-high',
        '--apk',
        'chrome_stable',
        '-g',
        '129.0.6626.0',
        '-b',
        '129.0.6628.0',
        '--no-local-cache',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    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_work_for_PIE(self):
    options = 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)
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidReleaseBuild)
    self.assertEqual(build.binary_name, 'MonochromeStable.apk')

  def test_get_download_url(self):
    options = 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.AndroidReleaseBuild)
    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 = 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.AndroidReleaseBuild)
    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 AndroidArm64HighOfficialBuildTest(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 = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'android-arm64-high',
        '--apk',
        'chrome',
        '-g',
        '1334338',
        '-b',
        '1334380',
        '--no-local-cache',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidOfficialBuild)
    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 = 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.AndroidOfficialBuild)
    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 = 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.AndroidOfficialBuild)
    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',
    )

  @patch('sys.stdout', new_callable=io.StringIO)
  @patch(
    'glob.glob',
    side_effect=[[], ['temp-dir/TrichromeChromeGoogle6432Canary.minimal.apks']],
  )
  @patch('bisect-builds.UnzipFilenameToDir')
  def test_install_revision_with_show_available_apks(
    self, mock_UnzipFilenameToDir, mock_glob, mock_stdout
  ):
    options = 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.AndroidOfficialBuild)
    with self.assertRaises(bisect_builds.BisectException):
      build._install_revision('download.zip', 'tmp-dir')
    self.assertIn("The list of available --apk:", mock_stdout.getvalue())
    self.assertIn("chrome_canary", mock_stdout.getvalue())

  @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 = 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.AndroidOfficialBuild)
    download_job = build.get_download_job(1334339)
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    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')

  @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_webview_apk_with_unsupported_versions(
    self, mock_LaunchOnAndroid, mock_InstallOnAndroid
  ):
    options = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'android-arm64-high',
        '--apk',
        'webview',
        '-g',
        '100000',
        '-b',
        '100010',
      ]
    )

    with self.assertRaises(bisect_builds.BisectException):
      _ = bisect_builds.create_archive_build(options)

  @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_webview_launch_revision(
    self, mock_LaunchOnAndroid, mock_InstallOnAndroid
  ):
    options = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'android-arm64-high',
        '--apk',
        'webview',
        '-g',
        '1350000',
        '-b',
        '1350010',
      ]
    )

    build = bisect_builds.create_archive_build(options)

    self.assertIsInstance(build, bisect_builds.AndroidOfficialBuild)
    download_job = build.get_download_job(1334339)
    zip_file = download_job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(zip_file, tempdir, [])
    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/TrichromeWebViewGoogle6432.minimal.apks$',
    )
    mock_LaunchOnAndroid.assert_called_once_with(self.device, 'webview')

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('bisect-builds.InstallOnAndroid')
  @patch('sys.stdout', new_callable=io.StringIO)
  @patch(
    'glob.glob',
    side_effect=[
      [],
      ['temp-dir/full-build-linux/apks/SystemWebView.apk'],
    ],
  )
  def test_install_revision_webview_swap_apk_filename(
    self, mock_glob, mock_stdout, mock_InstallOnAndroid, mock_unzip
  ):
    options = bisect_builds.ParseCommandLine(
      [
        'o',
        '-a',
        'android-arm',
        '-g',
        '1313161',
        '-b',
        '1313210',
        '--apk',
        'webview',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.AndroidSnapshotBuild)
    self.assertEqual(build.binary_name, 'SystemWebViewGoogle.apk')
    build._install_revision('webview.zip', 'temp-dir')
    self.assertEqual(build.binary_name, 'SystemWebView.apk')
    self.assertEqual(mock_glob.call_count, 2)
    mock_glob.assert_any_call('temp-dir/*/apks/SystemWebViewGoogle.apk')
    mock_glob.assert_any_call('temp-dir/*/apks/SystemWebView.apk')
    mock_InstallOnAndroid.assert_called_once_with(
      self.device, 'temp-dir/full-build-linux/apks/SystemWebView.apk'
    )
    self.assertIn('Retrying with SystemWebView.apk', mock_stdout.getvalue())


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 = 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', {'chrome': 'temp-dir/linux64/chrome'}, []
    )
    mock_Popen.assert_called_once_with(
      'temp-dir/linux64/chrome --user-data-dir=temp-dir/profile --no-sandbox',
      cwd=None,
      shell=True,
      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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios',
        '--ipa=canary.ipa',
        '--device-id',
        '321',
        '-g',
        '127.0.6533.74',
        '-b',
        '127.0.6533.78',
        '--no-local-cache',
      ]
    )
    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 = 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,
          shell=False,
          bufsize=-1,
          stdout=-1,
          stderr=-1,
        ),
        call(
          [
            'plutil',
            '-extract',
            'CFBundleIdentifier',
            'raw',
            'Payload/canary.app/Info.plist',
          ],
          cwd=None,
          shell=False,
          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 = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios',
        '--ipa=canary.ipa',
        '--device-id',
        '321',
        '-g',
        '127.0.6533.74',
        '-b',
        '127.0.6533.78',
        '--',
        'args1',
        'args2',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSReleaseBuild)
    build._launch_revision('tempdir', 'com.google.chrome.ios', options.args)
    mock_Popen.assert_any_call(
      [
        'xcrun',
        'devicectl',
        'device',
        'process',
        'launch',
        '--device',
        '321',
        'com.google.chrome.ios',
        'args1',
        'args2',
      ],
      cwd=None,
      shell=False,
      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.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision(self, mock_run):
    options = 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()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(ipa, tempdir, options.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',
          ]
        ),
      ]
    )


class IOSSimulatorReleaseBuildTest(BisectTestCase):
  @maybe_patch(
    'bisect-builds.GsutilList',
    side_effect=[
      [
        'gs://bling-archive/128.0.6534.0/',
        'gs://bling-archive/128.0.6534.1/',
        'gs://bling-archive/128.0.6535.0/',
        'gs://bling-archive/128.0.6535.1/',
        'gs://bling-archive/128.0.6536.0/',
      ],
      [
        'gs://bling-archive/128.0.6534.0/20240612011643/Chromium.tar.gz',
        'gs://bling-archive/128.0.6536.0/20240613011356/Chromium.tar.gz',
      ],
    ],
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, '', ''))
  def test_list_rev(self, mock_run, mock_GsutilList):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios-simulator',
        '--device-id',
        '321',
        '-g',
        '128.0.6534.0',
        '-b',
        '128.0.6536.0',
        '--no-local-cache',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSSimulatorReleaseBuild)
    self.assertEqual(build.get_rev_list(), ['128.0.6534.0', '128.0.6536.0'])
    mock_run.assert_called_once_with(['xcrun', 'simctl', 'boot', '321'])
    mock_GsutilList.assert_any_call('gs://bling-archive')
    mock_GsutilList.assert_any_call(
      *[
        'gs://bling-archive/%s/*/Chromium.tar.gz' % x
        for x in [
          '128.0.6534.0',
          '128.0.6534.1',
          '128.0.6535.0',
          '128.0.6535.1',
          '128.0.6536.0',
        ]
      ],
      ignore_fail=True,
    )

  @patch('bisect-builds.UnzipFilenameToDir')
  @patch('glob.glob', return_value=['Info.plist'])
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, '', ''))
  def test_install_revision(self, mock_run, mock_glob, mock_UnzipFilenameToDir):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios-simulator',
        '--device-id',
        '321',
        '-g',
        '128.0.6534.0',
        '-b',
        '128.0.6539.0',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSSimulatorReleaseBuild)
    build._install_revision('Chromium.tar.gz', 'tempdir')
    mock_UnzipFilenameToDir.assert_called_once_with(
      'Chromium.tar.gz', 'tempdir'
    )
    self.assertEqual(mock_glob.call_count, 2)
    mock_run.assert_has_calls(
      [
        call(['xcrun', 'simctl', 'boot', '321']),
        call(['xcrun', 'simctl', 'install', '321', ANY]),
        call(['plutil', '-extract', 'CFBundleIdentifier', 'raw', 'Info.plist']),
      ]
    )

  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, '', ''))
  def test_launch_revision(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios-simulator',
        '--device-id',
        '321',
        '-g',
        '128.0.6534.0',
        '-b',
        '128.0.6539.0',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSSimulatorReleaseBuild)
    build._launch_revision(
      'tempdir', 'com.google.chrome.ios.dev', ['args1', 'args2']
    )
    mock_run.assert_any_call(
      [
        'xcrun',
        'simctl',
        'launch',
        '321',
        'com.google.chrome.ios.dev',
        'args1',
        'args2',
      ]
    )

  @unittest.skipUnless(
    'NO_MOCK_SERVER' in os.environ, 'The test only valid when NO_MOCK_SERVER'
  )
  @patch('bisect-builds.ArchiveBuild._run', return_value=(0, 'stdout', ''))
  def test_run_revision(self, mock_run):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'ios-simulator',
        '--device-id',
        '321',
        '-g',
        '128.0.6534.0',
        '-b',
        '128.0.6539.0',
      ]
    )
    build = bisect_builds.create_archive_build(options)
    self.assertIsInstance(build, bisect_builds.IOSSimulatorReleaseBuild)
    job = build.get_download_job('128.0.6534.0')
    download = job.start().wait_for()
    with tempfile.TemporaryDirectory(prefix='bisect_tmp') as tempdir:
      build.run_revision(download, tempdir, options.args)
    mock_run.assert_has_calls(
      [
        call(['xcrun', 'simctl', 'boot', '321']),
        call(['xcrun', 'simctl', 'install', '321', ANY]),
        call(['plutil', '-extract', 'CFBundleIdentifier', 'raw', ANY]),
        call(['xcrun', 'simctl', 'launch', '321', 'stdout']),
      ]
    )


class MaybeSwitchBuildTypeTest(BisectTestCase):
  def test_generate_new_command_without_cache(self):
    command_line = [
      '-r',
      '-a',
      'linux64',
      '-g',
      '127.0.6533.74',
      '-b',
      '127.0.6533.88',
      '--no-local-cache',
    ]
    options = bisect_builds.ParseCommandLine(command_line)
    with patch('sys.argv', ['bisect-builds.py', *command_line]):
      new_cmd = bisect_builds.MaybeSwitchBuildType(
        options,
        bisect_builds.ChromiumVersion('127.0.6533.74'),
        bisect_builds.ChromiumVersion('127.0.6533.88'),
      )
      self.assertEqual(
        new_cmd[1:],
        [
          '-o',
          '-a',
          'linux64',
          '-g',
          '127.0.6533.74',
          '-b',
          '127.0.6533.88',
          '--verify-range',
          '--no-local-cache',
        ],
      )

  def test_android_signed_with_args(self):
    command_line = [
      '-r',
      '--archive=android-arm64-high',
      '--good=127.0.6533.74',
      '-b',
      '127.0.6533.88',
      '--apk=chrome',
      '--signed',
      '--no-local-cache',
      '--',
      'args1',
      '--args2',
    ]
    options = bisect_builds.ParseCommandLine(command_line)
    with patch('sys.argv', ['bisect-builds.py', *command_line]):
      new_cmd = bisect_builds.MaybeSwitchBuildType(
        options, '127.0.6533.74', '127.0.6533.88'
      )
      self.assertEqual(
        new_cmd[1:],
        [
          '-o',
          '-a',
          'android-arm64-high',
          '-g',
          '127.0.6533.74',
          '-b',
          '127.0.6533.88',
          '--verify-range',
          '--apk=chrome',
          '--no-local-cache',
          '--',
          'args1',
          '--args2',
        ],
      )

  def test_no_official_build(self):
    command_line = [
      '-r',
      '-a',
      'ios',
      '--ipa=canary.ipa',
      '--device-id',
      '321',
      '-g',
      '127.0.6533.74',
      '-b',
      '127.0.6533.78',
      '--no-local-cache',
    ]
    options = bisect_builds.ParseCommandLine(command_line)
    with patch('sys.argv', ['bisect-builds.py', *command_line]):
      new_cmd = bisect_builds.MaybeSwitchBuildType(
        options, '127.0.6533.74', '127.0.6533.88'
      )
      self.assertEqual(new_cmd, None)

  @patch('bisect-builds.ArchiveBuild.get_rev_list', return_value=list(range(3)))
  def test_generate_suggestion_with_cache(self, mock_get_rev_list):
    command_line = [
      '-r',
      '-a',
      'linux64',
      '-g',
      '127.0.6533.74',
      '-b',
      '127.0.6533.88',
      '--use-local-cache',
    ]
    options = bisect_builds.ParseCommandLine(command_line)
    with patch('sys.argv', ['bisect-builds.py', *command_line]):
      new_cmd = bisect_builds.MaybeSwitchBuildType(
        options, '127.0.6533.74', '127.0.6533.88'
      )
      self.assertEqual(
        new_cmd[1:],
        [
          '-o',
          '-a',
          'linux64',
          '-g',
          '127.0.6533.74',
          '-b',
          '127.0.6533.88',
          '--verify-range',
          '--use-local-cache',
        ],
      )
      mock_get_rev_list.assert_called()


class MethodTest(BisectTestCase):
  @patch('sys.stderr', new_callable=io.StringIO)
  def test_ParseCommandLine(self, mock_stderr):
    opts = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '1', 'args1', 'args2 3', '-b', '2']
    )
    self.assertEqual(opts.build_type, 'snapshot')
    self.assertEqual(opts.args, ['args1', 'args2 3'])

    opts = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '1', 'args1', 'args2 3']
    )
    self.assertEqual(opts.args, ['args1', 'args2 3'])

    opts = bisect_builds.ParseCommandLine(
      ['-a', 'linux64', '-g', '1', '--', 'args1', 'args2 3', '-b', '2']
    )
    self.assertEqual(opts.args, ['args1', 'args2 3', '-b', '2'])

    with self.assertRaises(SystemExit):
      bisect_builds.ParseCommandLine(['-a', 'mac64', '-o', '-g', '1'])
      self.assertRegexpMatches(
        mock_stderr.getvalue(),
        r'To bisect for mac64, please choose from '
        r'release(-r), snapshot(-s)',
      )

  @patch('bisect-builds._DetectArchive', return_value='linux64')
  def test_ParseCommandLine_DetectArchive(self, mock_detect_archive):
    opts = bisect_builds.ParseCommandLine(['-o', '-g', '1'])
    self.assertEqual(opts.archive, 'linux64')

  def test_ParseCommandLine_default_apk(self):
    opts = bisect_builds.ParseCommandLine(
      ['-o', '-a', 'android-arm', '-g', '1']
    )
    self.assertEqual(opts.apk, 'chrome')

    opts = bisect_builds.ParseCommandLine(['-a', 'android-arm64', '-g', '1'])
    self.assertEqual(opts.apk, 'chromium')

  def test_ParseCommandLine_default_ipa(self):
    opts = bisect_builds.ParseCommandLine(
      ['-r', '-a', 'ios', '-g', '127.0.6533.74', '-b', '127.0.6533.88']
    )
    self.assertEqual(opts.ipa, 'canary')

  def test_ParseCommandLine_DetectArchive_with_apk(self):
    opts = bisect_builds.ParseCommandLine(['-o', '--apk', 'chrome', '-g', '1'])
    self.assertEqual(opts.archive, 'android-arm64-high')

  def test_ParseCommandLine_DetectArchive_with_apk_Snapshot(self):
    opts = bisect_builds.ParseCommandLine(['-s', '--apk', 'chrome', '-g', '1'])
    self.assertEqual(opts.archive, 'android-arm64')

  def test_ParseCommandLine_DetectArchive_with_ipa(self):
    opts = bisect_builds.ParseCommandLine(
      ['-r', '--ipa', 'stable', '-g', '127.0.6533.74', '-b', '127.0.6533.88']
    )
    self.assertEqual(opts.archive, 'ios-simulator')

  @patch('sys.stderr', new_callable=io.StringIO)
  def test_ParseCommandLine_apk_error(self, mock_stderr):
    with self.assertRaises(SystemExit):
      bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '--apk', 'chrome', '-g', '1']
      )
    self.assertIn('--apk is only supported', mock_stderr.getvalue())

  @patch('sys.stderr', new_callable=io.StringIO)
  def test_ParseCommandLine_ipa_error(self, mock_stderr):
    with self.assertRaises(SystemExit):
      bisect_builds.ParseCommandLine(
        ['-a', 'linux64', '--ipa', 'stable', '-g', '1']
      )
    self.assertIn('--ipa is only supported', mock_stderr.getvalue())

  @patch('sys.stderr', new_callable=io.StringIO)
  def test_ParseCommandLine_signed_error(self, mock_stderr):
    with self.assertRaises(SystemExit):
      bisect_builds.ParseCommandLine(['-a', 'linux64', '--signed', '-g', '1'])
    self.assertIn('--signed is only supported', mock_stderr.getvalue())

  @patch('sys.stderr', new_callable=io.StringIO)
  def test_ParseCommandLine_webview_incompatibility_error(self, mock_stderr):
    with self.assertRaises(SystemExit):
      _ = bisect_builds.ParseCommandLine(
        [
          '-r',
          '-a',
          'android-arm64-high',
          '-g',
          '127.0.6533.76',
          '-b',
          '127.0.6533.79',
          '--apk',
          'webview',
        ]
      )
    self.assertIn(
      'Bisecting WebView for android-arm64-high, please choose official '
      'builds (-o)',
      mock_stderr.getvalue(),
    )

    opts = bisect_builds.ParseCommandLine(
      [
        '-o',
        '-a',
        'android-arm64-high',
        '-g',
        '1334017',
        '-b',
        '1335078',
        '--apk',
        'webview',
      ]
    )
    self.assertEqual(opts.apk, 'webview')
    self.assertEqual(opts.archive, 'android-arm64-high')
    self.assertEqual(opts.build_type, 'official')

  @patch('urllib.request.urlopen')
  @patch('builtins.open')
  @patch('sys.stdout', new_callable=io.StringIO)
  def test_update_script(self, mock_stdout, mock_open, mock_urlopen):
    mock_urlopen.return_value = io.BytesIO(
      base64.b64encode('content'.encode('utf-8'))
    )
    with self.assertRaises(SystemExit):
      bisect_builds.ParseCommandLine(['--update-script'])
    mock_urlopen.assert_called_once_with(
      'https://chromium.googlesource.com/chromium/src/+/HEAD/'
      'tools/bisect-builds.py?format=TEXT'
    )
    mock_open.assert_called_once()
    mock_open.return_value.__enter__().write.assert_called_once_with('content')
    self.assertEqual(mock_stdout.getvalue(), 'Update successful!\n')

  @patch(
    "urllib.request.urlopen",
    side_effect=[
      urllib.request.HTTPError('url', 404, 'Not Found', None, None),
      urllib.request.HTTPError('url', 404, 'Not Found', None, None),
      io.BytesIO(b"NOT_A_JSON"),
      io.BytesIO(b'{"chromium_main_branch_position": 123}'),
    ],
  )
  def test_GetRevisionFromVersion(self, mock_urlopen):
    self.assertEqual(
      123, bisect_builds.GetRevisionFromVersion('127.0.6533.134')
    )
    mock_urlopen.assert_has_calls(
      [
        call(
          'https://chromiumdash.appspot.com/fetch_version'
          '?version=127.0.6533.134'
        ),
        call(
          'https://chromiumdash.appspot.com/fetch_version?version=127.0.6533.0'
        ),
      ]
    )

  @maybe_patch(
    "urllib.request.urlopen",
    side_effect=[
      io.BytesIO(b'{"chromium_main_branch_position": null}'),
      io.BytesIO(b'{"message": "DEP\\n"}'),
      io.BytesIO(
        b')]}\'\n{"message": "Cr-Branched-From: '
        b'3d60439cfb36485e76a1c5bb7f513d3721b20da1-'
        b'refs/heads/master@{#870763}\\n"}'
      ),
    ],
  )
  def test_GetRevisionFromSourceTag(self, mock_urlopen):
    self.assertEqual(
      870763, bisect_builds.GetRevisionFromVersion('91.0.4472.38')
    )
    mock_urlopen.assert_has_calls(
      [
        call(
          'https://chromiumdash.appspot.com/fetch_version?version=91.0.4472.38'
        ),
        call(
          'https://chromium.googlesource.com/chromium/src/'
          '+/refs/tags/91.0.4472.38?format=JSON'
        ),
        call(
          'https://chromium.googlesource.com/chromium/src/'
          '+/refs/tags/91.0.4472.38^?format=JSON'
        ),
      ]
    )

  def test_join_args(self):
    test_data = ['a', 'b c', 'C:\\a b\\c', '/a b/c', '"a"', "'a'"]
    quoted_command = bisect_builds.join_args(
      [sys.executable, '-c', 'import sys, json; print(json.dumps(sys.argv))']
      + test_data
    )

    subproc = subprocess.Popen(
      quoted_command, shell=True, stdout=subprocess.PIPE
    )
    stdout, _ = subproc.communicate()
    dumped_argv = json.loads(stdout.decode('utf-8'))

    self.assertListEqual(dumped_argv, ['-c'] + test_data)


class ChromiumVersionTest(BisectTestCase):
  def test_cmpare_version_numbers(self):
    v127_0_6533_74 = bisect_builds.ChromiumVersion('127.0.6533.74')
    v127_0_6533_75 = bisect_builds.ChromiumVersion('127.0.6533.75')
    v127_0_6533_75_with_space = bisect_builds.ChromiumVersion('127.0.6533.75 ')
    v127 = bisect_builds.ChromiumVersion('127')

    self.assertLess(v127_0_6533_74, v127_0_6533_75)
    self.assertLessEqual(v127_0_6533_74, v127_0_6533_75)
    self.assertGreater(v127_0_6533_75, v127_0_6533_74)
    self.assertGreaterEqual(v127_0_6533_75, v127_0_6533_74)
    self.assertEqual(v127_0_6533_75, v127_0_6533_75_with_space)
    self.assertLess(v127, v127_0_6533_74)


class GetEarliestBuildVersionFromRevisionTest(BisectTestCase):
  @patch('bisect-builds.FetchJsonFromURL')
  def test_get_build_versions_from_revision(self, mock_fetch_json):
    mock_fetch_json.return_value = {'commits': [{'earliest': '144.0.7502.0'}]}
    version = bisect_builds.GetEarliestBuildVersionFromRevision(1537356)
    self.assertIsNotNone(version)
    self.assertEqual(str(version), '144.0.7502.0')

  @patch('bisect-builds.FetchJsonFromURL')
  def test_get_build_versions_from_revision_multiple_commits(
    self, mock_fetch_json
  ):
    mock_fetch_json.return_value = {
      'commits': [{'earliest': '144.0.7559.3'}, {'earliest': '144.0.7502.0'}]
    }
    with self.assertRaises(bisect_builds.BisectException):
      bisect_builds.GetEarliestBuildVersionFromRevision(1537356)

  @patch('bisect-builds.FetchJsonFromURL')
  def test_get_build_versions_from_revision_no_commits(self, mock_fetch_json):
    mock_fetch_json.return_value = {'commits': []}
    with self.assertRaises(bisect_builds.BisectException):
      bisect_builds.GetEarliestBuildVersionFromRevision(1537356)

  @patch('bisect-builds.FetchJsonFromURL')
  def test_get_build_versions_from_revision_no_earliest(self, mock_fetch_json):
    mock_fetch_json.return_value = {'commits': [{}]}
    with self.assertRaises(bisect_builds.BisectException):
      bisect_builds.GetEarliestBuildVersionFromRevision(1537356)

  @patch('bisect-builds.FetchJsonFromURL')
  def test_get_build_versions_from_revision_non_integer_revision(
    self, mock_fetch_json
  ):
    version = bisect_builds.GetEarliestBuildVersionFromRevision('not-a-number')
    self.assertIsNone(version)
    mock_fetch_json.assert_not_called()


class AndroidDesktopReleaseBuildTest(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',
        ]
      ],
      [
        'gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
        'gs://chrome-unsigned/android-B0urB0N/129.0.6626.1/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
        'gs://chrome-unsigned/android-B0urB0N/129.0.6627.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
        'gs://chrome-unsigned/android-B0urB0N/129.0.6627.1/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
        'gs://chrome-unsigned/android-B0urB0N/129.0.6628.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
      ],
    ],
  )
  def test_get_rev_list(self, mock_GsutilList):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-desktop-x64',
        '--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.AndroidDesktopReleaseBuild)
    self.assertEqual(
      build.get_rev_list(),
      [
        '129.0.6626.0',
        '129.0.6626.1',
        '129.0.6627.0',
        '129.0.6627.1',
        '129.0.6628.0',
      ],
    )
    mock_GsutilList.assert_has_calls(
      [
        call('gs://chrome-unsigned/android-B0urB0N'),
        call(
          'gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
          'gs://chrome-unsigned/android-B0urB0N/129.0.6626.1/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
          'gs://chrome-unsigned/android-B0urB0N/129.0.6627.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
          'gs://chrome-unsigned/android-B0urB0N/129.0.6627.1/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
          'gs://chrome-unsigned/android-B0urB0N/129.0.6628.0/desktop-x86_64/TrichromeChromeGoogleDesktop64Stable.apks',
          ignore_fail=True,
        ),
      ]
    )

  def test_get_download_url(self):
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-desktop-x64',
        '--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.AndroidDesktopReleaseBuild)
    download_urls = build.get_download_url('129.0.6626.0')
    self.assertDictEqual(
      download_urls,
      {
        'trichrome': (
          'gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/desktop-x86_64/'
          'TrichromeChromeGoogleDesktop64Stable.apks'
        ),
        'trichrome_library': (
          'gs://chrome-unsigned/android-B0urB0N/129.0.6626.0/desktop-x86_64/'
          'TrichromeLibraryGoogleDesktop64Stable.apk'
        ),
      },
    )

  @patch('bisect-builds.InstallOnAndroid')
  def test_install_revision(self, mock_InstallOnAndroid):
    downloads = {
      'trichrome': 'some-file.apks',
      'trichrome_library': 'file2.apk',
    }
    options = bisect_builds.ParseCommandLine(
      [
        '-r',
        '-a',
        'android-desktop-x64',
        '--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.AndroidDesktopReleaseBuild)
    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')


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