#!/usr/bin/env vpython3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for test_suite_mapper.py."""

import contextlib
import io
import pathlib
import unittest
from unittest.mock import MagicMock, patch

from pyfakefs import fake_filesystem_unittest

import test_suite_mapper

MOCK_ISOLATE_MAP = {
  '//chrome/test:browser_tests': 'browser_tests',
  '//chrome/test:unit_tests': 'unit_tests',
  '//media/midi:midi_unittests': 'midi_unittests',
  '//media:media_unittests': 'media_unittests',
  '//chrome/browser:foo_tests': 'foo_tests',
  '//content/test:bar_tests': 'bar_tests',
  '//foo/bar:baz_tests': 'baz_tests',
  '//base:base_unittests': 'base_unittests',
  '//:blink_wpt_tests': 'blink_wpt_tests',
}


class TestSuiteMapperUnit(fake_filesystem_unittest.TestCase):
  def setUp(self):
    self.setUpPyfakefs()
    self.subprocess_patcher = patch('subprocess.run')
    self.mock_run = self.subprocess_patcher.start()
    self.addCleanup(self.subprocess_patcher.stop)

  def test_common_dir_prefix_len(self):
    self.assertEqual(
      test_suite_mapper.common_dir_prefix_len('a/b/c', 'a/b/c'), 3
    )
    self.assertEqual(
      test_suite_mapper.common_dir_prefix_len('a/b/c', 'a/b/d'), 2
    )
    self.assertEqual(
      test_suite_mapper.common_dir_prefix_len('a/b/c', 'x/y/z'), 0
    )
    self.assertEqual(test_suite_mapper.common_dir_prefix_len('a/b/c', ''), 0)
    self.assertEqual(test_suite_mapper.common_dir_prefix_len('a/b', 'a/b/c'), 2)

  def test_parse_isolate_map_valid(self):
    content = (
      "{'browser_tests': {'label': '//chrome/test:browser_tests'},"
      " 'unit_tests': {'label': '//chrome/test:unit_tests'}}"
    )
    expected = {
      '//chrome/test:browser_tests': 'browser_tests',
      '//chrome/test:unit_tests': 'unit_tests',
    }
    self.assertEqual(
      test_suite_mapper.parse_isolate_map(content, 'test'), expected
    )

  def test_parse_isolate_map_missing_label(self):
    content = "{'suite1': {'type': 'additional_compile_target'}}"
    self.assertEqual(test_suite_mapper.parse_isolate_map(content, 'test'), {})

  def test_parse_isolate_map_invalid(self):
    content = 'invalid dict'
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      self.assertIsNone(test_suite_mapper.parse_isolate_map(content, 'test'))
    self.assertTrue(len(f.getvalue()) > 0)

  def test_parse_isolate_map_not_dict(self):
    content = '[1, 2, 3]'
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      self.assertIsNone(test_suite_mapper.parse_isolate_map(content, 'test'))
    self.assertIn('Expected a dictionary', f.getvalue())

  def test_load_isolate_map_success(self):
    self.fs.create_dir('/src')
    pyl_path = pathlib.Path('/src') / test_suite_mapper.ISOLATE_MAP_PATH
    self.fs.create_file(
      pyl_path,
      contents="{'browser_tests': {'label': '//chrome/test:browser_tests'}}",
    )
    isolate_map = test_suite_mapper.load_isolate_map('/src')
    self.assertEqual(
      isolate_map, {'//chrome/test:browser_tests': 'browser_tests'}
    )

  def test_load_isolate_map_missing_file(self):
    self.fs.create_dir('/src')
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      self.assertIsNone(test_suite_mapper.load_isolate_map('/src'))
    self.assertIn('Isolate map not found', f.getvalue())

  def test_load_isolate_map_read_error(self):
    self.fs.create_dir('/src')
    pyl_path = pathlib.Path('/src') / test_suite_mapper.ISOLATE_MAP_PATH
    self.fs.create_dir(pyl_path)
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      self.assertIsNone(test_suite_mapper.load_isolate_map('/src'))
    self.assertIn('Error reading', f.getvalue())

  def test_extract_test_suites_basic(self):
    refs = [
      '//chrome/browser:foo_tests',
      '//content/test:bar_tests',
      '//foo/bar:baz_tests',
    ]
    suites = test_suite_mapper.extract_test_suites(
      refs, pathlib.Path('chrome/browser/foo.cc'), MOCK_ISOLATE_MAP
    )
    self.assertEqual(suites, ['foo_tests'])

  def test_extract_test_suites_proximity(self):
    refs = [
      '//chrome/test:browser_tests',
      '//media/midi:midi_unittests',
    ]
    suites = test_suite_mapper.extract_test_suites(
      refs, pathlib.Path('media/midi/midi_manager.cc'), MOCK_ISOLATE_MAP
    )
    self.assertEqual(suites, ['midi_unittests'])

  def test_extract_test_suites_filter_non_isolate(self):
    refs = [
      '//media/base:unit_tests',
      '//media:media_unittests',
    ]
    suites = test_suite_mapper.extract_test_suites(
      refs,
      pathlib.Path('media/base/audio_bus_unittest.cc'),
      MOCK_ISOLATE_MAP,
    )
    self.assertEqual(suites, ['media_unittests'])

  def test_extract_test_suites_empty_target(self):
    refs = ['//chrome/browser:foo_tests', '', '  ']
    suites = test_suite_mapper.extract_test_suites(
      refs, pathlib.Path('chrome/browser/foo.cc'), MOCK_ISOLATE_MAP
    )
    self.assertEqual(suites, ['foo_tests'])

  def test_extract_test_suites_no_match(self):
    refs = ['//unrelated:target']
    suites = test_suite_mapper.extract_test_suites(
      refs, pathlib.Path('chrome/browser/foo.cc'), MOCK_ISOLATE_MAP
    )
    self.assertEqual(suites, [])

  def test_extract_test_suites_all_zero_score(self):
    refs = ['//media/midi:midi_unittests', '//foo/bar:baz_tests']
    suites = test_suite_mapper.extract_test_suites(
      refs, pathlib.Path('chrome/browser/foo.cc'), MOCK_ISOLATE_MAP
    )
    self.assertEqual(sorted(suites), ['baz_tests', 'midi_unittests'])

  def test_verify_build_dir_valid(self):
    self.fs.create_file(
      '/src/out/Default/args.gn',
      contents=(
        '# This is a comment\ntarget_os = "linux"\n\nuse_goma = true\n'
      ),
    )
    success, err = test_suite_mapper.verify_build_dir('/src', 'out/Default')
    self.assertTrue(success)
    self.assertIsNone(err)

  def test_verify_build_dir_missing_file(self):
    self.fs.create_dir('/src')
    success, err = test_suite_mapper.verify_build_dir('/src', 'out/Default')
    self.assertFalse(success)
    self.assertIn('args.gn not found', err)

  def test_verify_build_dir_invalid_os(self):
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "android"\n'
    )
    success, err = test_suite_mapper.verify_build_dir('/src', 'out/Default')
    self.assertFalse(success)
    self.assertIn('Unsupported target_os: android', err)

  @patch('sys.platform', 'linux')
  def test_verify_build_dir_no_target_os_on_linux(self):
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='is_debug = true\n'
    )
    success, err = test_suite_mapper.verify_build_dir('/src', 'out/Default')
    self.assertTrue(success)
    self.assertIsNone(err)

  @patch('sys.platform', 'darwin')
  def test_verify_build_dir_no_target_os_on_mac(self):
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='is_debug = true\n'
    )
    success, err = test_suite_mapper.verify_build_dir('/src', 'out/Default')
    self.assertFalse(success)
    self.assertIn('Host platform darwin is not Linux', err)

  def test_run_gn_refs_calls_gn(self):
    self.mock_run.return_value = MagicMock(
      returncode=0, stdout='//foo:foo_unittests\n', stderr=''
    )
    suites, err = test_suite_mapper.run_gn_refs(
      '/src', 'out/Default', pathlib.Path('foo/bar.cc')
    )
    self.assertEqual(suites, ['//foo:foo_unittests'])
    self.assertIsNone(err)
    self.mock_run.assert_called_once_with(
      [
        'gn',
        'refs',
        'out/Default',
        '--all',
        '//foo/bar.cc',
      ],
      capture_output=True,
      text=True,
      cwd='/src',
      check=False,
      encoding='utf-8',
    )

  def test_run_gn_refs_failure(self):
    self.mock_run.return_value = MagicMock(
      returncode=1, stdout='', stderr='GN error message\n'
    )
    suites, err = test_suite_mapper.run_gn_refs(
      '/src', 'out/Default', pathlib.Path('foo/bar.cc')
    )
    self.assertIsNone(suites)
    self.assertEqual(err, 'GN error message')

  def test_run_gn_refs_exception(self):
    self.mock_run.side_effect = OSError('Command not found')
    suites, err = test_suite_mapper.run_gn_refs(
      '/src', 'out/Default', pathlib.Path('foo/bar.cc')
    )
    self.assertIsNone(suites)
    self.assertEqual(err, 'Command not found')

  def test_get_build_gn_paths(self):
    refs = [
      '//chrome/browser:foo_tests',
      '//content/test:bar_tests',
      '//:blink_wpt_tests',
      'invalid_target',
    ]
    paths = test_suite_mapper.get_build_gn_paths(refs)
    self.assertEqual(
      paths,
      ['BUILD.gn', 'chrome/browser/BUILD.gn', 'content/test/BUILD.gn'],
    )

  def test_check_graph_stability_stable(self):
    self.mock_run.return_value = MagicMock(returncode=0, stdout='')
    refs = ['//chrome/browser:foo_tests']
    stable, changed = test_suite_mapper.check_graph_stability(
      '/src', 'abc123f', 'chrome/browser/foo.cc', refs
    )
    self.assertTrue(stable)
    self.assertEqual(changed, [])
    self.mock_run.assert_called_once_with(
      [
        'git',
        'diff',
        '--name-only',
        'abc123f..HEAD',
        '--',
        'chrome/browser/foo.cc',
        'chrome/browser/BUILD.gn',
      ],
      capture_output=True,
      text=True,
      cwd='/src',
      check=False,
      encoding='utf-8',
    )

  def test_check_graph_stability_unstable(self):
    self.mock_run.return_value = MagicMock(
      returncode=0,
      stdout='chrome/browser/BUILD.gn\n',
    )
    refs = ['//chrome/browser:foo_tests']
    stable, changed = test_suite_mapper.check_graph_stability(
      '/src', 'abc123f', 'chrome/browser/foo.cc', refs
    )
    self.assertFalse(stable)
    self.assertEqual(changed, ['chrome/browser/BUILD.gn'])

  def test_check_graph_stability_git_fail(self):
    self.mock_run.return_value = MagicMock(
      returncode=1, stderr='Git diff error'
    )
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      stable, changed = test_suite_mapper.check_graph_stability(
        '/src', 'abc123f', 'chrome/browser/foo.cc', ['//chrome:foo']
      )
    self.assertFalse(stable)
    self.assertEqual(changed, [])
    self.assertIn('Stability check failed', f.getvalue())

  def test_get_ignored_paths(self):
    self.mock_run.return_value = MagicMock(
      returncode=0,
      stdout=('!! out/\n!! build/config/gclient_args.gni\n?? untracked_file\n'),
    )
    ignored = test_suite_mapper.get_ignored_paths('/src')
    self.assertEqual(ignored, ['out', 'build/config/gclient_args.gni'])

  def test_find_nested_repos_and_symlinks(self):
    self.fs.create_dir('/src/out')
    self.fs.create_dir('/src/.git')
    self.fs.create_dir('/src/third_party/foo')
    self.fs.create_file('/src/third_party/foo/.git')
    self.fs.create_dir('/src/third_party/bar/.git')
    self.fs.create_dir('/src/third_party/baz')

    # Deep nested repo (depth > 5)
    self.fs.create_file('/src/a/b/c/d/e/f/.git')

    # Symlinks
    self.fs.create_file('/src/third_party/baz/real_file')
    self.fs.create_symlink(
      '/src/third_party/link_to_file', '/src/third_party/baz/real_file'
    )
    self.fs.create_symlink(
      '/src/third_party/link_to_dir', '/src/third_party/baz'
    )
    self.fs.create_symlink(
      '/src/out/bad_link', '/src/third_party/baz/real_file'
    )

    # Symlink inside a nested repo should be skipped
    self.fs.create_symlink(
      '/src/third_party/foo/nested_link', '/src/third_party/baz/real_file'
    )

    targets = test_suite_mapper.find_nested_repos_and_symlinks('/src')
    expected = [
      'third_party/bar',
      'third_party/foo',
      'third_party/link_to_dir',
      'third_party/link_to_file',
    ]
    self.assertEqual(sorted(targets), expected)

  def test_stub_missing_target(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_dir('/wt')
    target_ref = '//third_party/foo:bar'
    success = test_suite_mapper._stub_missing_target(wt_path, target_ref)
    self.assertTrue(success)

    build_gn_path = wt_path / 'third_party' / 'foo' / 'BUILD.gn'
    self.assertTrue(build_gn_path.exists())
    content = build_gn_path.read_text(encoding='utf-8')
    self.assertIn('group("bar") {}', content)

  def test_stub_missing_target_failure(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_file('/wt/third_party')
    target_ref = '//third_party/foo:bar'
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      success = test_suite_mapper._stub_missing_target(wt_path, target_ref)
    self.assertFalse(success)
    self.assertTrue(len(f.getvalue()) > 0)

  def test_stub_missing_target_invalid_ref(self):
    wt_path = pathlib.Path('/wt')
    self.assertFalse(test_suite_mapper._stub_missing_target(wt_path, 'foo:bar'))
    self.assertFalse(
      test_suite_mapper._stub_missing_target(wt_path, '//foo/bar')
    )

  def test_spoof_rust_version_success(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_file(
      '/wt/tools/rust/update_rust.py',
      contents=(
        "RUST_REVISION = 'aaaa05163abcbd08948b3efab796c543ba1ea687'\n"
        "RUST_SUB_REVISION = 2\n"
      ),
    )
    src_dir = pathlib.Path('/src_rust')
    self.fs.create_file(
      '/src_rust/VERSION',
      contents=(
        'rustc 1.96.0 4c4205163abcbd08948b3efab796c543ba1ea687 '
        '(4c4205163abcbd08948b3efab796c543ba1ea687-5-'
        'llvmorg-23-init-10931-g20b6ec66 chromium)\n'
      ),
    )
    dest_dir = pathlib.Path('/dest_rust')
    self.fs.create_dir('/dest_rust')

    success = test_suite_mapper._spoof_rust_version(wt_path, src_dir, dest_dir)
    self.assertTrue(success)

    dest_version = dest_dir / 'VERSION'
    self.assertTrue(dest_version.exists())
    content = dest_version.read_text(encoding='utf-8')
    self.assertEqual(
      content,
      'rustc 1.96.0 aaaa05163abcbd08948b3efab796c543ba1ea687 '
      '(aaaa05163abcbd08948b3efab796c543ba1ea687-2-'
      'llvmorg-23-init-10931-g20b6ec66 chromium)\n',
    )

  def test_spoof_rust_version_missing_update_rust(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_dir('/wt')
    self.assertFalse(
      test_suite_mapper._spoof_rust_version(
        wt_path, pathlib.Path('/src'), pathlib.Path('/dest')
      )
    )

  def test_spoof_rust_version_invalid_update_rust(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_file(
      '/wt/tools/rust/update_rust.py', contents='invalid content\n'
    )
    self.assertFalse(
      test_suite_mapper._spoof_rust_version(
        wt_path, pathlib.Path('/src'), pathlib.Path('/dest')
      )
    )

  def test_spoof_rust_version_missing_src_version(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_file(
      '/wt/tools/rust/update_rust.py',
      contents="RUST_REVISION = 'aaaa'\nRUST_SUB_REVISION = 1\n",
    )
    self.assertFalse(
      test_suite_mapper._spoof_rust_version(
        wt_path, pathlib.Path('/src'), pathlib.Path('/dest')
      )
    )

  def test_spoof_rust_version_invalid_src_version_format(self):
    wt_path = pathlib.Path('/wt')
    self.fs.create_file(
      '/wt/tools/rust/update_rust.py',
      contents="RUST_REVISION = 'aaaa'\nRUST_SUB_REVISION = 1\n",
    )
    src_dir = pathlib.Path('/src')
    self.fs.create_file(src_dir / 'VERSION', contents='invalid format\n')
    self.assertFalse(
      test_suite_mapper._spoof_rust_version(
        wt_path, src_dir, pathlib.Path('/dest')
      )
    )

  @patch('test_suite_mapper.get_ignored_paths')
  @patch('test_suite_mapper.find_nested_repos_and_symlinks')
  def test_setup_worktree_symlinks(self, mock_find, mock_get_ignored):
    mock_get_ignored.return_value = ['third_party/llvm-build', 'out']
    mock_find.return_value = ['third_party/foo', 'third_party/link_to_file']

    self.fs.create_dir('/src')
    self.fs.create_dir('/src/third_party/llvm-build')
    self.fs.create_dir('/src/third_party/foo')
    self.fs.create_file('/src/third_party/link_to_file')
    self.fs.create_dir('/src/out/Default')
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "linux"\n'
    )
    self.fs.create_file('/src/out/Default/other_file', contents='ignored\n')

    self.fs.create_dir('/wt')

    test_suite_mapper._setup_worktree_symlinks(
      pathlib.Path('/src'), pathlib.Path('/wt'), 'out/Default'
    )

    self.assertTrue(pathlib.Path('/wt/third_party/llvm-build').is_symlink())
    self.assertTrue(pathlib.Path('/wt/third_party/foo').is_symlink())
    self.assertTrue(pathlib.Path('/wt/third_party/link_to_file').is_symlink())
    self.assertFalse(pathlib.Path('/wt/out').is_symlink())
    self.assertFalse(pathlib.Path('/wt/out').exists())

  @patch('test_suite_mapper.get_ignored_paths')
  @patch('test_suite_mapper.find_nested_repos_and_symlinks')
  @patch('test_suite_mapper._spoof_rust_version')
  def test_setup_worktree_symlinks_rust_toolchain(
    self, mock_spoof, mock_find, mock_get_ignored
  ):
    mock_get_ignored.return_value = [
      test_suite_mapper.RUST_TOOLCHAIN_PATH.as_posix()
    ]
    mock_find.return_value = []
    mock_spoof.return_value = True

    self.fs.create_dir('/src')
    rust_dir = pathlib.Path('/src') / test_suite_mapper.RUST_TOOLCHAIN_PATH
    self.fs.create_file(rust_dir / 'bin/rustc')
    self.fs.create_file(rust_dir / 'VERSION')

    self.fs.create_dir('/wt')
    self.fs.create_file(
      pathlib.Path('/wt') / test_suite_mapper.UPDATE_RUST_PATH
    )

    test_suite_mapper._setup_worktree_symlinks(
      pathlib.Path('/src'), pathlib.Path('/wt'), 'out/Default'
    )

    link_rust_dir = pathlib.Path('/wt') / test_suite_mapper.RUST_TOOLCHAIN_PATH
    self.assertTrue((link_rust_dir / 'bin').is_symlink())
    self.assertTrue((link_rust_dir / 'bin/rustc').exists())
    self.assertFalse((link_rust_dir / 'VERSION').exists())
    mock_spoof.assert_called_once()

  def test_run_gn_gen_with_stubbing_success_immediate(self):
    self.mock_run.return_value = MagicMock(returncode=0)
    success = test_suite_mapper._run_gn_gen_with_stubbing(
      pathlib.Path('/wt'), 'out/Default'
    )
    self.assertTrue(success)
    self.mock_run.assert_called_once_with(
      ['gn', 'gen', 'out/Default'],
      capture_output=True,
      text=True,
      cwd=pathlib.Path('/wt'),
      check=False,
      encoding='utf-8',
    )

  def test_run_gn_gen_with_stubbing_retry_success(self):
    self.mock_run.side_effect = [
      MagicMock(
        returncode=1,
        stdout=(
          'ERROR Unresolved dependencies.\n  //foo:bar needs'
          ' //third_party/baz:qux\n'
        ),
        stderr='',
      ),
      MagicMock(returncode=0),
    ]
    self.fs.create_dir('/wt')
    success = test_suite_mapper._run_gn_gen_with_stubbing(
      pathlib.Path('/wt'), 'out/Default'
    )
    self.assertTrue(success)
    self.assertEqual(self.mock_run.call_count, 2)

    stub_path = pathlib.Path('/wt/third_party/baz/BUILD.gn')
    self.assertTrue(stub_path.exists())
    self.assertIn('group("qux") {}', stub_path.read_text(encoding='utf-8'))

  def test_run_gn_gen_with_stubbing_failure_loop(self):
    self.mock_run.return_value = MagicMock(
      returncode=1,
      stdout=(
        'ERROR Unresolved dependencies.\n  //foo:bar needs'
        ' //third_party/baz:qux\n'
      ),
      stderr='',
    )
    self.fs.create_dir('/wt')
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      success = test_suite_mapper._run_gn_gen_with_stubbing(
        pathlib.Path('/wt'), 'out/Default'
      )
    self.assertFalse(success)
    self.assertIn('already stubbed', f.getvalue())

  def test_run_heavyweight_resolution_success(self):
    isolate_map_content = (
      "{'browser_tests': {'label': '//chrome/test:browser_tests'}}"
    )

    def mock_run_side_effect(cmd, *args, **kwargs):
      if 'worktree' in cmd and 'add' in cmd:
        wt_path = cmd[5]
        self.fs.create_file(
          pathlib.Path(wt_path) / test_suite_mapper.ISOLATE_MAP_PATH,
          contents=isolate_map_content,
        )
        return MagicMock(returncode=0)
      elif 'gen' in cmd:
        return MagicMock(returncode=0)
      elif 'refs' in cmd:
        return MagicMock(
          returncode=0,
          stdout='//chrome/test:browser_tests\n',
          stderr='',
        )
      return MagicMock(returncode=0)

    self.mock_run.side_effect = mock_run_side_effect

    self.fs.create_dir('/src')
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "linux"\n'
    )

    with (
      patch('test_suite_mapper.get_ignored_paths', return_value=[]),
      patch(
        'test_suite_mapper.find_nested_repos_and_symlinks', return_value=[]
      ),
    ):
      suites = test_suite_mapper.run_heavyweight_resolution(
        pathlib.Path('/src'),
        'abc123f',
        'out/Default',
        pathlib.Path('chrome/browser/foo.cc'),
      )

    self.assertEqual(suites, ['browser_tests'])

    # Verify worktree add and remove were called with the same path
    add_path = None
    remove_path = None
    for call in self.mock_run.call_args_list:
      cmd = call[0][0]
      if 'worktree' in cmd and 'add' in cmd:
        add_path = cmd[5]
      if 'worktree' in cmd and 'remove' in cmd:
        remove_path = cmd[4]

    self.assertIsNotNone(add_path)
    self.assertIsNotNone(remove_path)
    self.assertEqual(add_path, remove_path)

  def test_run_heavyweight_resolution_worktree_fail(self):
    self.mock_run.return_value = MagicMock(returncode=1, stderr='Git error')

    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      suites = test_suite_mapper.run_heavyweight_resolution(
        pathlib.Path('/src'),
        'abc123f',
        'out/Default',
        pathlib.Path('chrome/browser/foo.cc'),
      )
    self.assertIsNone(suites)
    self.assertIn('Error creating git worktree', f.getvalue())

  def test_run_heavyweight_resolution_gn_gen_fail(self):
    self.mock_run.side_effect = [
      MagicMock(returncode=0),  # worktree add
      MagicMock(returncode=1, stdout='GN gen error', stderr=''),  # gn gen fail
      MagicMock(returncode=0),  # worktree remove
    ]
    self.fs.create_dir('/src')
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "linux"\n'
    )
    f = io.StringIO()
    with (
      patch('test_suite_mapper.get_ignored_paths', return_value=[]),
      patch(
        'test_suite_mapper.find_nested_repos_and_symlinks', return_value=[]
      ),
      contextlib.redirect_stderr(f),
    ):
      suites = test_suite_mapper.run_heavyweight_resolution(
        pathlib.Path('/src'),
        'abc123f',
        'out/Default',
        pathlib.Path('chrome/browser/foo.cc'),
      )
    self.assertIsNone(suites)

  def test_run_heavyweight_resolution_gn_refs_fail(self):
    self.mock_run.side_effect = [
      MagicMock(returncode=0),  # worktree add
      MagicMock(returncode=0),  # gn gen
      MagicMock(
        returncode=1, stdout='', stderr='GN refs error'
      ),  # gn refs fail
      MagicMock(returncode=0),  # worktree remove
    ]
    self.fs.create_dir('/src')
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "linux"\n'
    )
    f = io.StringIO()
    with (
      patch('test_suite_mapper.get_ignored_paths', return_value=[]),
      patch(
        'test_suite_mapper.find_nested_repos_and_symlinks', return_value=[]
      ),
      contextlib.redirect_stderr(f),
    ):
      suites = test_suite_mapper.run_heavyweight_resolution(
        pathlib.Path('/src'),
        'abc123f',
        'out/Default',
        pathlib.Path('chrome/browser/foo.cc'),
      )
    self.assertIsNone(suites)

  def test_run_heavyweight_resolution_missing_isolate_map(self):
    self.mock_run.side_effect = [
      MagicMock(returncode=0),  # worktree add
      MagicMock(returncode=0),  # gn gen
      MagicMock(returncode=0, stdout='//foo:bar\n', stderr=''),  # gn refs
      MagicMock(returncode=0),  # worktree remove
    ]
    self.fs.create_dir('/src')
    self.fs.create_file(
      '/src/out/Default/args.gn', contents='target_os = "linux"\n'
    )
    f = io.StringIO()
    with (
      patch('test_suite_mapper.get_ignored_paths', return_value=[]),
      patch(
        'test_suite_mapper.find_nested_repos_and_symlinks', return_value=[]
      ),
      contextlib.redirect_stderr(f),
    ):
      suites = test_suite_mapper.run_heavyweight_resolution(
        pathlib.Path('/src'),
        'abc123f',
        'out/Default',
        pathlib.Path('chrome/browser/foo.cc'),
      )
    self.assertIsNone(suites)
    self.assertIn('Isolate map not found in worktree', f.getvalue())

  def test_load_isolate_map_for_args_with_file(self):
    content = "{'suite1': {'label': '//foo:bar'}}"
    self.fs.create_file('/src/map.pyl', contents=content)
    res = test_suite_mapper._load_isolate_map_for_args(
      '/src', '/src/map.pyl', None, False
    )
    self.assertEqual(res, {'//foo:bar': 'suite1'})

    with self.assertRaises(SystemExit):
      test_suite_mapper._load_isolate_map_for_args(
        '/src', '/src/missing.pyl', None, False
      )

  def test_load_isolate_map_for_args_revision(self):
    with self.assertRaises(SystemExit):
      test_suite_mapper._load_isolate_map_for_args(
        '/src', None, 'abc123f', False
      )

    res = test_suite_mapper._load_isolate_map_for_args(
      '/src', None, 'abc123f', True
    )
    self.assertIsNone(res)

  def test_load_isolate_map_for_args_default(self):
    with patch(
      'test_suite_mapper.load_isolate_map', return_value={'a': 'b'}
    ) as mock_load:
      res = test_suite_mapper._load_isolate_map_for_args(
        '/src', None, None, False
      )
      self.assertEqual(res, {'a': 'b'})
      mock_load.assert_called_once_with('/src')

  def test_check_and_warn_stability_stable(self):
    with patch(
      'test_suite_mapper.check_graph_stability', return_value=(True, [])
    ):
      stable = test_suite_mapper._check_and_warn_stability(
        '/src', 'abc123f', pathlib.Path('foo.cc'), ['//foo']
      )
      self.assertTrue(stable)

  def test_check_and_warn_stability_unstable(self):
    with patch(
      'test_suite_mapper.check_graph_stability',
      return_value=(False, ['BUILD.gn']),
    ):
      f = io.StringIO()
      with contextlib.redirect_stderr(f):
        stable = test_suite_mapper._check_and_warn_stability(
          '/src', 'abc123f', pathlib.Path('foo.cc'), ['//foo']
        )
      self.assertFalse(stable)
      self.assertIn(
        'Warning: The GN graph or the file may have changed', f.getvalue()
      )
      self.assertIn('BUILD.gn', f.getvalue())

    with patch(
      'test_suite_mapper.check_graph_stability',
      return_value=(False, ['f1', 'f2', 'f3', 'f4', 'f5', 'f6']),
    ):
      f = io.StringIO()
      with contextlib.redirect_stderr(f):
        stable = test_suite_mapper._check_and_warn_stability(
          '/src', 'abc123f', pathlib.Path('foo.cc'), ['//foo']
        )
      self.assertFalse(stable)
      self.assertIn('f1, f2, f3, f4, f5 and 1 more', f.getvalue())

  def test_should_run_heavyweight(self):
    self.assertTrue(
      test_suite_mapper._should_run_heavyweight(
        '/src', 'abc123f', pathlib.Path('foo.cc'), ['//foo'], True, True, []
      )
    )

    self.assertFalse(
      test_suite_mapper._should_run_heavyweight(
        '/src', 'abc123f', pathlib.Path('foo.cc'), ['//foo'], False, True, []
      )
    )

    with patch(
      'test_suite_mapper.load_isolate_map', return_value={'//foo': 'suite'}
    ):
      self.assertFalse(
        test_suite_mapper._should_run_heavyweight(
          '/src',
          'abc123f',
          pathlib.Path('foo.cc'),
          ['//foo'],
          False,
          False,
          ['suite'],
        )
      )

    with patch(
      'test_suite_mapper.load_isolate_map', return_value={'//foo': 'suite2'}
    ):
      f = io.StringIO()
      with contextlib.redirect_stderr(f):
        res = test_suite_mapper._should_run_heavyweight(
          '/src',
          'abc123f',
          pathlib.Path('foo.cc'),
          ['//foo'],
          False,
          False,
          ['suite1'],
        )
      self.assertTrue(res)
      self.assertIn('Difference detected', f.getvalue())

  @patch('test_suite_mapper.run_heavyweight_resolution')
  def test_execute_heavyweight_resolution_success(self, mock_resolve):
    mock_resolve.return_value = ['suite1']
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      res = test_suite_mapper._execute_heavyweight_resolution(
        '/src', 'abc123f', 'out/Default', pathlib.Path('foo.cc'), ['suite1']
      )
    self.assertEqual(res, ['suite1'])

    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      res = test_suite_mapper._execute_heavyweight_resolution(
        '/src', 'abc123f', 'out/Default', pathlib.Path('foo.cc'), ['suite2']
      )
    self.assertEqual(res, ['suite1'])
    self.assertIn('Results corrected', f.getvalue())

  @patch('test_suite_mapper.run_heavyweight_resolution')
  def test_execute_heavyweight_resolution_fail(self, mock_resolve):
    mock_resolve.return_value = None
    f = io.StringIO()
    with contextlib.redirect_stderr(f):
      res = test_suite_mapper._execute_heavyweight_resolution(
        '/src', 'abc123f', 'out/Default', pathlib.Path('foo.cc'), ['suite1']
      )
    self.assertEqual(res, ['suite1'])
    self.assertIn('Falling back to lightweight results', f.getvalue())

  def test_execute_heavyweight_resolution_no_rev(self):
    with self.assertRaises(SystemExit):
      test_suite_mapper._execute_heavyweight_resolution(
        '/src', None, 'out/Default', pathlib.Path('foo.cc'), []
      )


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