#!/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 run_local_coverage.py."""

import contextlib
import io
import pathlib
import sys
import unittest
from unittest import mock
import run_local_coverage as rlc


class RunLocalCoverageTest(unittest.TestCase):
  """Tests LLVM tool resolution, subprocess execution, profile merging,
  and main CLI workflow.
  """

  def setUp(self) -> None:
    """Sets up subprocess and LLVM tool path mocks."""
    self.run_patcher = mock.patch('subprocess.run')
    self.mock_run = self.run_patcher.start()
    self.addCleanup(self.run_patcher.stop)

    self.tool_patcher = mock.patch('run_local_coverage.find_llvm_tool')
    self.mock_tool = self.tool_patcher.start()
    self.mock_tool.return_value = pathlib.Path('/fake/bin/llvm-tool')
    self.addCleanup(self.tool_patcher.stop)

    self.check_patcher = mock.patch('subprocess.check_call')
    self.mock_check = self.check_patcher.start()
    self.addCleanup(self.check_patcher.stop)

  def test_find_llvm_tool_success(self) -> None:
    """Verifies tool resolution when executable exists."""
    self.tool_patcher.stop()
    with mock.patch.object(pathlib.Path, 'exists', return_value=True):
      res = rlc.find_llvm_tool('llvm-profdata')
      self.assertEqual(res.name, 'llvm-profdata')

  def test_find_llvm_tool_downloads_missing(self) -> None:
    """Verifies missing LLVM tool is downloaded automatically."""
    self.tool_patcher.stop()
    with mock.patch.object(pathlib.Path, 'exists', side_effect=[False, True]):
      res = rlc.find_llvm_tool('llvm-profdata')
      self.assertEqual(res.name, 'llvm-profdata')
      self.assertTrue(self.mock_check.called)

  def test_find_llvm_tool_download_failure(self) -> None:
    """Verifies sys.exit(1) when downloading LLVM tools fails."""
    self.tool_patcher.stop()
    self.mock_check.side_effect = rlc.subprocess.CalledProcessError(
      1, 'update.py'
    )
    with mock.patch.object(pathlib.Path, 'exists', return_value=False):
      err_buf = io.StringIO()
      with contextlib.redirect_stderr(err_buf):
        with self.assertRaises(SystemExit) as cm:
          rlc.find_llvm_tool('missing-tool')
      self.assertEqual(cm.exception.code, 1)
      self.assertIn('Failed to download', err_buf.getvalue())

  def test_find_llvm_tool_not_found_after_update(self) -> None:
    """Verifies sys.exit(1) when LLVM executable is still missing
    after update.
    """
    self.tool_patcher.stop()
    with mock.patch.object(pathlib.Path, 'exists', return_value=False):
      err_buf = io.StringIO()
      with contextlib.redirect_stderr(err_buf):
        with self.assertRaises(SystemExit) as cm:
          rlc.find_llvm_tool('missing-tool')
      self.assertEqual(cm.exception.code, 1)
      self.assertIn('still not found', err_buf.getvalue())

  def test_run_test_binary_injects_env(self) -> None:
    """Verifies LLVM_PROFILE_FILE is injected into subprocess env."""
    self.mock_run.return_value = mock.MagicMock(returncode=0)
    bin_path = pathlib.Path('out/Default/net_unittests')
    prof_dir = pathlib.Path('scratch/prof')
    res = rlc.run_test_binary(bin_path, prof_dir, ['--gtest_filter=Foo'])
    self.assertEqual(res, 0)
    _, kwargs = self.mock_run.call_args
    self.assertIn('LLVM_PROFILE_FILE', kwargs['env'])

  def test_merge_profiles_no_files(self) -> None:
    """Verifies merge returns False when profraw files are missing."""
    with mock.patch.object(pathlib.Path, 'glob', return_value=[]):
      err_buf = io.StringIO()
      with contextlib.redirect_stderr(err_buf):
        res = rlc.merge_profiles(
          pathlib.Path('dir'), pathlib.Path('out.profdata')
        )
      self.assertFalse(res)
      self.assertIn('No profraw files found', err_buf.getvalue())

  def test_merge_profiles_subprocess_failure(self) -> None:
    """Verifies merge returns False when llvm-profdata fails."""
    fake_files = [pathlib.Path('a.profraw')]
    self.mock_run.return_value = mock.MagicMock(returncode=1)
    with mock.patch.object(pathlib.Path, 'glob', return_value=fake_files):
      res = rlc.merge_profiles(
        pathlib.Path('dir'), pathlib.Path('out.profdata')
      )
      self.assertFalse(res)

  def test_merge_profiles_success(self) -> None:
    """Verifies merge returns True when llvm-profdata succeeds."""
    fake_files = [pathlib.Path('a.profraw')]
    self.mock_run.return_value = mock.MagicMock(returncode=0)
    with mock.patch.object(pathlib.Path, 'glob', return_value=fake_files):
      res = rlc.merge_profiles(
        pathlib.Path('dir'), pathlib.Path('out.profdata')
      )
      self.assertTrue(res)

  def test_export_coverage_failure(self) -> None:
    """Verifies llvm-cov failure returns None and prints stderr."""
    mock_proc = mock.MagicMock(returncode=1, stderr='bad profile')
    self.mock_run.return_value = mock_proc
    err_buf = io.StringIO()
    with contextlib.redirect_stderr(err_buf):
      out = rlc.export_coverage(
        pathlib.Path('bin'), pathlib.Path('prof.data'), None
      )
    self.assertIsNone(out)
    self.assertIn('llvm-cov failed', err_buf.getvalue())

  def test_export_coverage_success(self) -> None:
    """Verifies stdout is returned when llvm-cov succeeds."""
    mock_proc = mock.MagicMock(returncode=0, stdout='Coverage: 100%')
    self.mock_run.return_value = mock_proc
    out = rlc.export_coverage(
      pathlib.Path('bin'), pathlib.Path('prof.data'), 'foo.cc'
    )
    self.assertEqual(out, 'Coverage: 100%')
    args, _ = self.mock_run.call_args
    self.assertIn('foo.cc', args[0])

  def test_compile_binary_success(self) -> None:
    """Verifies compile_binary executes autoninja and returns True
    on success.
    """
    self.mock_run.return_value = mock.MagicMock(returncode=0)
    bin_path = pathlib.Path('out/Coverage/net_unittests')
    res = rlc.compile_binary(bin_path)
    self.assertTrue(res)
    self.mock_run.assert_called_once_with(
      ['autoninja', '-C', 'out/Coverage', 'net_unittests'], check=False
    )

  def test_compile_binary_failure(self) -> None:
    """Verifies compile_binary returns False on autoninja failure."""
    self.mock_run.return_value = mock.MagicMock(returncode=1)
    bin_path = pathlib.Path('out/Coverage/net_unittests')
    res = rlc.compile_binary(bin_path)
    self.assertFalse(res)

  def test_main_success_flow(self) -> None:
    """Verifies full CLI workflow printing report to stdout."""
    test_args = ['rlc.py', '--binary', 'net_unittests']
    out_buf = io.StringIO()
    err_buf = io.StringIO()
    with (
      mock.patch.object(pathlib.Path, 'mkdir'),
      mock.patch('run_local_coverage.compile_binary', return_value=True),
      mock.patch('run_local_coverage.run_test_binary', return_value=1),
      mock.patch('run_local_coverage.merge_profiles', return_value=True),
      mock.patch(
        'run_local_coverage.export_coverage', return_value='REPORT 100%'
      ),
      mock.patch.object(sys, 'argv', test_args),
      contextlib.redirect_stdout(out_buf),
      contextlib.redirect_stderr(err_buf),
    ):
      rlc.main()
    self.assertIn('REPORT 100%', out_buf.getvalue())
    self.assertIn('exited with 1', err_buf.getvalue())

  def test_main_compile_failure(self) -> None:
    """Verifies sys.exit(1) when compile_binary fails in main."""
    test_args = ['rlc.py', '--binary', 'net_unittests']
    err_buf = io.StringIO()
    with (
      mock.patch('run_local_coverage.compile_binary', return_value=False),
      mock.patch.object(sys, 'argv', test_args),
      contextlib.redirect_stderr(err_buf),
      self.assertRaises(SystemExit) as cm,
    ):
      rlc.main()
    self.assertEqual(cm.exception.code, 1)
    self.assertIn('Failed to compile target', err_buf.getvalue())

  def test_main_no_compile_flag(self) -> None:
    """Verifies passing --no-compile skips compile_binary."""
    test_args = ['rlc.py', '--binary', 'net_unittests', '--no-compile']
    with (
      mock.patch.object(pathlib.Path, 'mkdir'),
      mock.patch('run_local_coverage.compile_binary') as mock_compile,
      mock.patch('run_local_coverage.run_test_binary', return_value=0),
      mock.patch('run_local_coverage.merge_profiles', return_value=True),
      mock.patch('run_local_coverage.export_coverage', return_value='OK'),
      mock.patch.object(sys, 'argv', test_args),
    ):
      rlc.main()
    mock_compile.assert_not_called()

  def test_main_merge_failure(self) -> None:
    """Verifies sys.exit(1) when profile merge step fails in main."""
    test_args = ['rlc.py', '--binary', 'net_unittests']
    with (
      mock.patch.object(pathlib.Path, 'mkdir'),
      mock.patch('run_local_coverage.compile_binary', return_value=True),
      mock.patch('run_local_coverage.run_test_binary', return_value=0),
      mock.patch('run_local_coverage.merge_profiles', return_value=False),
      mock.patch.object(sys, 'argv', test_args),
      self.assertRaises(SystemExit) as cm,
    ):
      rlc.main()
    self.assertEqual(cm.exception.code, 1)


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