[PATCH 26/26] lucid: add unit tests for callback plugin

Daniel Gomez <[email protected]> Tue, 19 May 2026 15:28:23 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <[email protected]>
From: Daniel Gomez <[email protected]>

Introduce a unittest-based suite under tests/callback_plugins/ covering
the pure helpers and key state transitions in callback_plugins/lucid.py.
The suite mocks the Display object and builds lightweight stand-ins for
Ansible's CallbackTaskResult, TaskResult, Host, and Task so each test
can target one helper or hook in isolation without standing up a full
PlayIterator.

Coverage spans the plugin identity constants, DOCUMENTATION YAML
parseability and the default_callback / result_format_callback
fragments, the CI detection matrix in _detect_interactive, the bucket
boundaries of _format_duration, the ellipsis behaviour of
_truncate_line, the module dispatch table in _get_task_command, the
running-to-completed state machine in _handle_result, the failed_items
accumulator fed by v2_runner_item_on_failed, the non-mutating
_cleaned_result copy for debug-module redaction, the argspec noise
filter in v2_playbook_on_task_start, and the dynamic-mode update thread
shutdown path in _cleanup. Run with: cd kdevops && python3 -m unittest
discover -s tests -v (set ANSIBLE_LOCAL_TEMP / ANSIBLE_REMOTE_TEMP to a
writable path if the default ~/.ansible/tmp is not writable).

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 tests/__init__.py                    |   0
 tests/callback_plugins/__init__.py   |   0
 tests/callback_plugins/test_lucid.py | 386 +++++++++++++++++++++++++++++++++++
 3 files changed, 386 insertions(+)

diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/callback_plugins/__init__.py b/tests/callback_plugins/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/callback_plugins/test_lucid.py b/tests/callback_plugins/test_lucid.py
new file mode 100644
index 00000000..48fba641
--- /dev/null
+++ b/tests/callback_plugins/test_lucid.py
@@ -0,0 +1,386 @@
+"""Unit tests for the lucid Ansible callback plugin.
+
+Run with:
+
+    cd kdevops
+    python3 -m unittest discover -s tests -v
+
+The tests mock out the Ansible Display object and construct lightweight
+stand-ins for CallbackTaskResult / TaskResult / Host / Task rather than
+wiring up a full PlayIterator. This keeps the suite fast and lets each
+test target a single helper or state transition in isolation.
+
+If the environment cannot write to ~/.ansible/tmp (some sandboxes),
+set ANSIBLE_LOCAL_TEMP and ANSIBLE_REMOTE_TEMP to a writable path before
+running the suite.
+"""
+
+from __future__ import annotations
+
+import copy
+import os
+import sys
+import threading
+import time
+import unittest
+from unittest.mock import MagicMock, patch
+
+import yaml
+
+# Make the plugin importable without installing it.
+HERE = os.path.dirname(os.path.abspath(__file__))
+CALLBACK_DIR = os.path.abspath(os.path.join(HERE, "..", "..", "callback_plugins"))
+if CALLBACK_DIR not in sys.path:
+    sys.path.insert(0, CALLBACK_DIR)
+
+try:
+    import lucid  # noqa: E402
+    ANSIBLE_IMPORT_ERROR = None
+except Exception as exc:  # pragma: no cover - exercised only on broken envs
+    lucid = None
+    ANSIBLE_IMPORT_ERROR = exc
+
+
+ANSIBLE_REQUIRED = unittest.skipIf(
+    lucid is None,
+    f"lucid callback plugin could not be imported: {ANSIBLE_IMPORT_ERROR!r}",
+)
+
+
+def _make_callback():
+    """Build a CallbackModule with a mocked Display and pre-seeded options.
+
+    Tests that exercise runner hooks need a cached option set in place of
+    a real set_options() call (which would touch the config manager). We
+    populate the attributes that set_options() would normally fill so
+    downstream hooks behave deterministically.
+    """
+    cb = lucid.CallbackModule()
+    cb._display = MagicMock()
+    cb._display.verbosity = 0
+    cb._display.columns = 80
+    cb.dynamic_mode = False
+    cb.display_ok_hosts = True
+    cb.display_skipped_hosts = True
+    cb.display_failed_stderr = False
+    cb.check_mode_markers = False
+    cb.show_custom_stats = False
+    cb.show_task_path_on_failure = False
+    return cb
+
+
+@ANSIBLE_REQUIRED
+class TestCallbackMetadata(unittest.TestCase):
+    """Plugin identity constants must stay stable for Ansible to load it."""
+
+    def test_callback_version(self):
+        self.assertEqual(lucid.CallbackModule.CALLBACK_VERSION, 2.0)
+
+    def test_callback_type(self):
+        self.assertEqual(lucid.CallbackModule.CALLBACK_TYPE, "stdout")
+
+    def test_callback_name(self):
+        self.assertEqual(lucid.CallbackModule.CALLBACK_NAME, "lucid")
+
+
+@ANSIBLE_REQUIRED
+class TestDocumentation(unittest.TestCase):
+    """DOCUMENTATION must be valid YAML and carry the expected fragments."""
+
+    def test_documentation_parses(self):
+        doc = yaml.safe_load(lucid.DOCUMENTATION)
+        self.assertIsInstance(doc, dict)
+
+    def test_documentation_required_keys(self):
+        doc = yaml.safe_load(lucid.DOCUMENTATION)
+        for key in (
+            "name",
+            "type",
+            "author",
+            "short_description",
+            "description",
+            "options",
+            "extends_documentation_fragment",
+        ):
+            self.assertIn(key, doc, f"missing key: {key}")
+
+    def test_documentation_extends_fragments(self):
+        doc = yaml.safe_load(lucid.DOCUMENTATION)
+        fragments = doc["extends_documentation_fragment"]
+        self.assertIn("default_callback", fragments)
+        self.assertIn("result_format_callback", fragments)
+
+
+@ANSIBLE_REQUIRED
+class TestDetectInteractive(unittest.TestCase):
+    """_detect_interactive must flip to False in every common CI context."""
+
+    def _run(self, env, tty):
+        cb = _make_callback()
+        with patch.dict(os.environ, env, clear=True):
+            with patch("sys.stdout.isatty", return_value=tty):
+                with patch("sys.stderr.isatty", return_value=tty):
+                    return cb._detect_interactive()
+
+    def test_clean_tty_is_interactive(self):
+        self.assertTrue(self._run({}, tty=True))
+
+    def test_not_a_tty(self):
+        self.assertFalse(self._run({}, tty=False))
+
+    def test_ci_env(self):
+        self.assertFalse(self._run({"CI": "1"}, tty=True))
+
+    def test_jenkins_env(self):
+        self.assertFalse(self._run({"JENKINS_HOME": "/x"}, tty=True))
+
+    def test_github_actions_env(self):
+        self.assertFalse(self._run({"GITHUB_ACTIONS": "true"}, tty=True))
+
+    def test_gitlab_ci_env(self):
+        self.assertFalse(self._run({"GITLAB_CI": "true"}, tty=True))
+
+    def test_term_dumb(self):
+        self.assertFalse(self._run({"TERM": "dumb"}, tty=True))
+
+
+@ANSIBLE_REQUIRED
+class TestFormatDuration(unittest.TestCase):
+    """_format_duration bucket boundaries.
+
+    The helper uses strict < comparisons (e.g. < 60, < 3600, < 86400), so
+    the values tested here hit each bucket exactly.
+    """
+
+    def setUp(self):
+        self.cb = _make_callback()
+
+    def test_sub_minute_uses_seconds(self):
+        self.assertEqual(self.cb._format_duration(30), "30.0s")
+
+    def test_one_minute_flat(self):
+        self.assertEqual(self.cb._format_duration(60), "1m 0s")
+
+    def test_sub_hour_upper_bound(self):
+        self.assertEqual(self.cb._format_duration(3599), "59m 59s")
+
+    def test_one_hour_flat(self):
+        self.assertEqual(self.cb._format_duration(3600), "1h 0m")
+
+    def test_sub_day_upper_bound(self):
+        self.assertEqual(self.cb._format_duration(86399), "23h 59m")
+
+    def test_one_day_flat(self):
+        self.assertEqual(self.cb._format_duration(86400), "1d 0h")
+
+    def test_width_right_aligns(self):
+        result = self.cb._format_duration(30, width=8)
+        self.assertEqual(result, "   30.0s")
+        self.assertEqual(len(result), 8)
+
+
+@ANSIBLE_REQUIRED
+class TestTruncateLine(unittest.TestCase):
+    """_truncate_line must preserve short input and trim long input with '...'."""
+
+    def setUp(self):
+        self.cb = _make_callback()
+
+    def test_empty_string(self):
+        self.assertEqual(self.cb._truncate_line("", 80), "")
+
+    def test_shorter_than_width(self):
+        self.assertEqual(self.cb._truncate_line("hello", 80), "hello")
+
+    def test_equal_to_width(self):
+        line = "a" * 10
+        self.assertEqual(self.cb._truncate_line(line, 10), line)
+
+    def test_longer_than_width_gets_ellipsis(self):
+        line = "a" * 20
+        result = self.cb._truncate_line(line, 10)
+        self.assertEqual(result, "aaaaaaa...")
+        self.assertEqual(len(result), 10)
+
+    def test_tiny_width_returns_bare_ellipsis(self):
+        # When max_width is exactly 3, the helper returns "..." for any
+        # overflow rather than producing a degenerate output.
+        self.assertEqual(self.cb._truncate_line("abcdef", 3), "...")
+
+
+@ANSIBLE_REQUIRED
+class TestGetTaskCommand(unittest.TestCase):
+    """_get_task_command dispatches on the module action."""
+
+    def setUp(self):
+        self.cb = _make_callback()
+
+    def _result(self, action, res):
+        r = MagicMock()
+        r._task.action = action
+        r._result = res
+        return r
+
+    def test_shell_with_string_cmd(self):
+        r = self._result("ansible.builtin.shell", {"cmd": "echo hi"})
+        self.assertEqual(self.cb._get_task_command(r), "echo hi")
+
+    def test_shell_with_list_cmd(self):
+        r = self._result("ansible.builtin.shell", {"cmd": ["echo", "hi"]})
+        self.assertEqual(self.cb._get_task_command(r), "echo hi")
+
+    def test_command_module(self):
+        r = self._result("ansible.builtin.command", {"cmd": "ls -la"})
+        self.assertEqual(self.cb._get_task_command(r), "ls -la")
+
+    def test_unsupported_module_returns_none(self):
+        r = self._result("ansible.builtin.ping", {})
+        self.assertIsNone(self.cb._get_task_command(r))
+
+    def test_community_make_uses_command_key(self):
+        r = self._result("community.general.make", {"command": "make -j4"})
+        self.assertEqual(self.cb._get_task_command(r), "make -j4")
+
+    def test_shell_missing_cmd_returns_none(self):
+        # Previously there was a fallback to task.args["cmd"] that returned
+        # the raw Jinja template; the fallback was removed, so a missing
+        # cmd key in the result must resolve to None.
+        r = self._result("ansible.builtin.shell", {})
+        self.assertIsNone(self.cb._get_task_command(r))
+
+
+@ANSIBLE_REQUIRED
+class TestHandleResult(unittest.TestCase):
+    """_handle_result drives the running/completed state machine."""
+
+    def test_ok_result_promotes_task_to_completed(self):
+        cb = _make_callback()
+
+        result = MagicMock()
+        result._host.name = "localhost"
+        result._task.action = "ansible.builtin.command"
+        result._task._uuid = "uuid-1"
+        result._task.get_name.return_value = "test task"
+        result._task.get_path.return_value = None
+        result._result = {
+            "cmd": "echo hi",
+            "stdout": "hi",
+            "stderr": "",
+            "changed": True,
+        }
+
+        # Simulate v2_runner_on_start having seen this task.
+        cb.running_tasks[("localhost", "uuid-1")] = {
+            "start_time": time.time(),
+            "host": "localhost",
+            "delegate_to": None,
+            "task_name": "test task",
+        }
+
+        cb.v2_runner_on_ok(result)
+
+        self.assertEqual(len(cb.completed_tasks), 1)
+        entry = cb.completed_tasks[0]
+        self.assertEqual(entry["host"], "localhost")
+        self.assertEqual(entry["status"], "changed")
+        self.assertNotIn(("localhost", "uuid-1"), cb.running_tasks)
+
+
+@ANSIBLE_REQUIRED
+class TestItemOnFailed(unittest.TestCase):
+    """v2_runner_item_on_failed must push a structured entry into failed_items."""
+
+    def test_failed_item_records_label_and_streams(self):
+        cb = _make_callback()
+
+        result = MagicMock()
+        result._task.action = "ansible.builtin.command"
+        result._result = {
+            "_ansible_item_label": "item-1",
+            "stderr": "oops",
+            "stdout": "",
+            "msg": "failed",
+        }
+
+        cb.v2_runner_item_on_failed(result)
+
+        self.assertEqual(len(cb.failed_items), 1)
+        entry = cb.failed_items[0]
+        self.assertEqual(entry["item"], "item-1")
+        self.assertEqual(entry["stderr"], "oops")
+        self.assertEqual(entry["msg"], "failed")
+
+
+@ANSIBLE_REQUIRED
+class TestCleanedResult(unittest.TestCase):
+    """_cleaned_result must return a sanitized copy without mutating the original."""
+
+    def test_debug_module_strips_bookkeeping(self):
+        cb = _make_callback()
+
+        result = MagicMock()
+        result._task.action = "ansible.builtin.debug"
+        original = {
+            "changed": True,
+            "invocation": "foo --bar",
+            "msg": "hello",
+        }
+        result._result = copy.deepcopy(original)
+
+        cleaned = cb._cleaned_result(result)
+
+        # _clean_results strips changed / invocation for debug tasks but
+        # leaves the user-facing msg in place.
+        self.assertNotIn("changed", cleaned)
+        self.assertNotIn("invocation", cleaned)
+        self.assertEqual(cleaned.get("msg"), "hello")
+        # The original result dict is never mutated by the helper.
+        self.assertEqual(result._result, original)
+
+
+@ANSIBLE_REQUIRED
+class TestArgspecFilter(unittest.TestCase):
+    """Empty argspec validation tasks must be suppressed from the task header."""
+
+    def test_argspec_task_does_not_overwrite_current_task(self):
+        cb = _make_callback()
+        cb.current_task_name = "real task"
+
+        argspec_task = MagicMock()
+        argspec_task.get_name.return_value = (
+            "Validating arguments against arg spec None"
+        )
+
+        cb.v2_playbook_on_task_start(argspec_task, is_conditional=False)
+
+        self.assertEqual(cb.current_task_name, "real task")
+
+
+@ANSIBLE_REQUIRED
+class TestThreadCleanup(unittest.TestCase):
+    """_cleanup must stop the update thread and leave the terminal usable."""
+
+    def test_update_thread_stops_on_cleanup(self):
+        cb = _make_callback()
+        cb.dynamic_mode = True
+        cb.display_lines = 0
+
+        # Mimic _start_update_thread without triggering atexit registration,
+        # which would leak a real thread across the test suite.
+        cb.update_thread_stop = threading.Event()
+        cb.update_thread = threading.Thread(target=cb._update_loop)
+        cb.update_thread.start()
+
+        # Let the update loop enter its first iteration.
+        time.sleep(0.05)
+        self.assertTrue(cb.update_thread.is_alive())
+
+        cb._cleanup()
+
+        self.assertTrue(cb.update_thread_stop.is_set())
+        # _cleanup joins with a 2s timeout; confirm the thread has exited.
+        self.assertFalse(cb.update_thread.is_alive())
+
+
+if __name__ == "__main__":
+    unittest.main()

-- 
2.53.0