blob: 1fc7d691d4173b9783e60491442bdbd4848aa34f [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
from agents.lib.githooks import adapters
from agents.lib.githooks.reporters import ConsoleReporter
from agents_testing.base import BaseTestCase
class AdaptersTest(unittest.TestCase):
"""Unit tests for githooks.adapters dataclasses and definitions."""
def test_hook_action_definition(self) -> None:
action = adapters.HookAction(
name="test_action",
action_fn=lambda ctx, files: True,
is_mutating=True,
extensions=[".py"],
remediation_cmd_fn=lambda files: [f"fix {','.join(files)}"],
)
self.assertEqual(action.name, "test_action")
self.assertTrue(action.is_mutating)
self.assertEqual(action.extensions, [".py"])
self.assertIsNotNone(action.remediation_cmd_fn)
if action.remediation_cmd_fn:
self.assertEqual(action.remediation_cmd_fn(["a.py"]), ["fix a.py"])
self.assertTrue(len(adapters.DEFAULT_PRE_COMMIT_ACTIONS) > 0)
default_cmd_fn = adapters.DEFAULT_PRE_COMMIT_ACTIONS[
0
].remediation_cmd_fn
self.assertIsNotNone(default_cmd_fn)
if default_cmd_fn:
self.assertEqual(
default_cmd_fn(["a.py"]),
["fx format-code --files=a.py"],
)
class FormatCodeActionTest(BaseTestCase):
"""Unit tests for format_code_action using HookContext.reporter."""
def make_mock_reporter(
self,
spec: Any = None,
) -> mock.MagicMock:
"""Factory helper to construct a mock ConsoleReporter."""
return mock.MagicMock(spec=spec) if spec else mock.MagicMock()
def setUp(self) -> None:
super().setUp()
(self.test_dir / ".fx-root").touch()
def test_format_code_action_empty(self) -> None:
ctx = adapters.HookContext(repo_root=self.test_dir)
self.assertTrue(adapters.format_code_action(ctx, []))
def test_format_code_action_runs_fx_format_code(self) -> None:
ctx = adapters.HookContext(repo_root=self.test_dir)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = self.make_completed_process()
ok = adapters.format_code_action(ctx, ["a.py", "b.cc"])
self.assertTrue(ok)
mock_cmd.assert_called_once_with(
["fx", "format-code", "--files=a.py,b.cc"],
cwd=self.test_dir,
)
def test_format_code_action_error_reporting(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = self.make_completed_process(
returncode=1, stderr="Error formatting code"
)
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once_with(
"Error formatting code"
)
def test_format_code_action_check_only_modified_non_destructive_returns_false(
self,
) -> None:
ctx = adapters.HookContext(
repo_root=self.test_dir,
check_only=True,
)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.side_effect = [
# 1. fx format-code
self.make_completed_process(),
# 2. git diff --quiet -- foo.py
self.make_completed_process(returncode=1),
]
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertFalse(ok)
self.assertEqual(
mock_cmd.call_args_list,
[
mock.call(
["fx", "format-code", "--files=foo.py"],
cwd=self.test_dir,
),
mock.call(
["git", "diff", "--quiet", "--", "foo.py"],
cwd=self.test_dir,
),
],
)
def test_format_code_action_check_only_unmodified_returns_true(
self,
) -> None:
ctx = adapters.HookContext(
repo_root=self.test_dir,
check_only=True,
)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.side_effect = [
# 1. fx format-code
self.make_completed_process(),
# 2. git diff --quiet -- foo.py
self.make_completed_process(returncode=0),
]
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertTrue(ok)
self.assertEqual(
mock_cmd.call_args_list,
[
mock.call(
["fx", "format-code", "--files=foo.py"],
cwd=self.test_dir,
),
mock.call(
["git", "diff", "--quiet", "--", "foo.py"],
cwd=self.test_dir,
),
],
)
def test_format_code_action_resolves_fx_from_scripts_when_not_in_path(
self,
) -> None:
scripts_dir = self.test_dir / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
fx_script = scripts_dir / "fx"
fx_script.touch()
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
)
with (
mock.patch("shutil.which", return_value=None),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = self.make_completed_process()
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertTrue(ok)
mock_cmd.assert_called_once_with(
[str(fx_script), "format-code", "--files=foo.py"],
cwd=self.test_dir,
)
def test_format_code_action_prioritizes_scripts_fx_over_ambient_path(
self,
) -> None:
scripts_dir = self.test_dir / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
fx_script = scripts_dir / "fx"
fx_script.touch()
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
)
with (
mock.patch("shutil.which", return_value="/ambient/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = self.make_completed_process()
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertTrue(ok)
mock_cmd.assert_called_once_with(
[str(fx_script), "format-code", "--files=foo.py"],
cwd=self.test_dir,
)
def test_format_code_action_missing_fx_not_in_path_or_scripts_human(
self,
) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with (
mock.patch("shutil.which", return_value=None),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_called_once()
mock_cmd.assert_not_called()
def test_format_code_action_missing_fx_not_in_path_or_scripts_agent(
self,
) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with (
mock.patch("shutil.which", return_value=None),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once()
mock_cmd.assert_not_called()
def test_format_code_action_combined_stdout_and_stderr_error(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="Formatter stdout output",
stderr="Formatter stderr output",
)
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once_with(
"Formatter stdout output\nFormatter stderr output"
)
def test_format_code_action_empty_output_fallback_error(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with (
mock.patch("shutil.which", return_value="/bin/fx"),
mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd,
):
mock_cmd.return_value = self.make_completed_process(
returncode=2, stderr=""
)
ok = adapters.format_code_action(ctx, ["foo.py"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once()
err_msg = mock_reporter.on_error.call_args[0][0]
self.assertIn("failed with exit code 2", err_msg)
class CommitMsgActionTest(BaseTestCase):
"""Unit tests for commit_msg_action using HookContext.reporter."""
def make_mock_reporter(
self,
spec: Any = None,
) -> mock.MagicMock:
"""Factory helper to construct a mock ConsoleReporter."""
return mock.MagicMock(spec=spec) if spec else mock.MagicMock()
def setUp(self) -> None:
super().setUp()
(self.test_dir / ".fx-root").touch()
self.checker_script = (
self.test_dir / "scripts" / "shac" / "commit_msg_checker.py"
)
self.checker_script.parent.mkdir(parents=True, exist_ok=True)
self.checker_script.touch()
def test_commit_msg_action_empty(self) -> None:
ctx = adapters.HookContext(repo_root=self.test_dir)
self.assertTrue(adapters.commit_msg_action(ctx, []))
def test_commit_msg_action_runs_checker_strict_for_agent(self) -> None:
ctx = adapters.HookContext(repo_root=self.test_dir, is_agent=True)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = self.make_completed_process()
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_cmd.assert_called_once()
cmd_args = mock_cmd.call_args[0][0]
self.assertIn("--strict", cmd_args)
def test_commit_msg_action_error_reporting(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = self.make_completed_process(
returncode=1, stderr="Title too long"
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once_with("Title too long")
def test_commit_msg_action_warning_human(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="Warning: check format",
stderr="",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_called_once_with(
"Warning: check format"
)
def test_commit_msg_action_warning_human_with_stderr_noise(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="Warning: subject line exceeds 50 chars",
stderr="Some python warning\nDeprecationWarning: ...\n",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_called_once_with(
"Warning: subject line exceeds 50 chars"
)
def test_commit_msg_action_success_with_stderr_noise_no_warning(
self,
) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = self.make_completed_process(
returncode=0,
stderr="Some python warning\n",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_not_called()
def test_commit_msg_action_error_combines_stdout_and_stderr(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="[ERROR] Missing Change-Id line",
stderr="Traceback (most recent call last):\n File 'test.py', line 1\n",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once_with(
"[ERROR] Missing Change-Id line\nTraceback (most recent call last):\n File 'test.py', line 1"
)
def test_commit_msg_action_empty_output_fallback_error(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = self.make_completed_process(
returncode=3, stderr=""
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once()
err_msg = mock_reporter.on_error.call_args[0][0]
self.assertIn("failed with exit code 3", err_msg)
def test_commit_msg_action_checker_script_missing_human(self) -> None:
self.checker_script.unlink()
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_called_once()
mock_reporter.on_error.assert_not_called()
def test_commit_msg_action_checker_script_missing_agent(self) -> None:
self.checker_script.unlink()
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once()
mock_reporter.on_warning.assert_not_called()
def test_commit_msg_action_multiline_warnings_human(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=False,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="Warning: line 1\n\n Warning: line 2 \n",
stderr="",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_reporter.on_warning.assert_called_once_with(
"Warning: line 1\n\n Warning: line 2"
)
def test_commit_msg_action_multiline_errors_agent(self) -> None:
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="Error: line 1\n\n Error: line 2 \n",
stderr="",
)
ok = adapters.commit_msg_action(ctx, ["COMMIT_EDITMSG"])
self.assertFalse(ok)
mock_reporter.on_error.assert_called_once_with(
"Error: line 1\n\n Error: line 2"
)
def test_commit_msg_action_relative_path_from_subdirectory(self) -> None:
subdir = self.test_dir / "nested" / "sub"
subdir.mkdir(parents=True, exist_ok=True)
mock_reporter = self.make_mock_reporter(ConsoleReporter)
ctx = adapters.HookContext(
repo_root=self.test_dir,
is_agent=True,
reporter=mock_reporter,
)
with mock.patch("agents.lib.githooks.adapters.run_cmd") as mock_cmd:
mock_cmd.return_value = self.make_completed_process()
self.addCleanup(os.chdir, Path.cwd())
os.chdir(subdir)
ok = adapters.commit_msg_action(ctx, [".git/COMMIT_EDITMSG"])
self.assertTrue(ok)
mock_cmd.assert_called_once()
cmd_args = mock_cmd.call_args[0][0]
self.assertIn(".git/COMMIT_EDITMSG", cmd_args)
if __name__ == "__main__":
unittest.main()