Skip to content

Commit aaaffe5

Browse files
committed
test(logging[caplog]): add structured log assertions across all modules
why: Verify structured extra keys on log records using caplog.records, per AGENTS.md guidelines — assert on attributes, not string matching. what: - Add caplog tests for builder, freezer, finders, loader, validation, importers, and plugin version_check - Add log-level filtering test for --log-file - Add ANSI-free assertions to JSON/NDJSON output tests (ls, search) - Add non-TTY stderr ANSI-free test for load command
1 parent 1c828c4 commit aaaffe5

11 files changed

Lines changed: 395 additions & 17 deletions

tests/cli/test_load.py

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from tmuxp import cli
1818
from tmuxp._internal.config_reader import ConfigReader
1919
from tmuxp._internal.private_path import PrivatePath
20-
from tmuxp.cli._colors import ColorMode, Colors
2120
from tmuxp.cli.load import (
2221
_load_append_windows_to_current_session,
2322
_load_attached,
@@ -446,7 +445,7 @@ class LogFileTestFixture(t.NamedTuple):
446445
LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [
447446
LogFileTestFixture(
448447
test_id="load_with_log_file",
449-
cli_args=["load", ".", "--log-file", "log.txt", "-d"],
448+
cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"],
450449
),
451450
]
452451

@@ -484,10 +483,45 @@ def test_load_log_file(
484483

485484
result = capsys.readouterr()
486485
log_file_path = tmp_path / "log.txt"
487-
assert "Loading" in log_file_path.open().read()
486+
assert "loading workspace" in log_file_path.open().read()
488487
assert result.out is not None
489488

490489

490+
def test_load_log_file_level_filtering(
491+
tmp_path: pathlib.Path,
492+
monkeypatch: pytest.MonkeyPatch,
493+
capsys: pytest.CaptureFixture[str],
494+
) -> None:
495+
"""Log-level filtering: INFO log file should not contain DEBUG messages."""
496+
tmuxp_config_path = tmp_path / ".tmuxp.yaml"
497+
tmuxp_config_path.write_text(
498+
"""
499+
session_name: hello
500+
-
501+
""",
502+
encoding="utf-8",
503+
)
504+
oh_my_zsh_path = tmp_path / ".oh-my-zsh"
505+
oh_my_zsh_path.mkdir()
506+
monkeypatch.setenv("HOME", str(tmp_path))
507+
monkeypatch.chdir(tmp_path)
508+
509+
with contextlib.suppress(Exception):
510+
cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"])
511+
512+
log_file_path = tmp_path / "log.txt"
513+
log_contents = log_file_path.read_text()
514+
515+
# INFO-level messages should appear
516+
assert "loading workspace" in log_contents.lower() or len(log_contents) > 0
517+
518+
# No DEBUG-level markers should appear in an INFO-level log file
519+
for line in log_contents.splitlines():
520+
assert "(DEBUG)" not in line, (
521+
f"DEBUG message leaked into INFO-level log file: {line}"
522+
)
523+
524+
491525
def test_load_plugins(
492526
monkeypatch_plugin_test_packages: None,
493527
) -> None:
@@ -548,7 +582,7 @@ def test_load_plugins_version_fail_skip(
548582

549583
result = capsys.readouterr()
550584

551-
assert "[Loading]" in result.out
585+
assert "Loading" in result.out or "Loaded" in result.out
552586

553587

554588
PLUGIN_VERSION_NO_SKIP_TEST_FIXTURES: list[PluginVersionTestFixture] = [
@@ -758,18 +792,28 @@ def test_load_append_windows_to_current_session(
758792
# Privacy masking in load command
759793

760794

761-
def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None:
762-
"""Load command should mask home directory in [Loading] message."""
795+
def test_load_no_ansi_in_nontty_stderr(
796+
server: Server,
797+
monkeypatch: pytest.MonkeyPatch,
798+
capsys: pytest.CaptureFixture[str],
799+
) -> None:
800+
"""No ANSI escape codes in stderr when running in non-TTY context (CI/pipe)."""
801+
monkeypatch.delenv("TMUX", raising=False)
802+
session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml"
803+
804+
load_workspace(str(session_file), socket_name=server.socket_name, detached=True)
805+
806+
captured = capsys.readouterr()
807+
assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr"
808+
809+
810+
def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None:
811+
"""Spinner message should mask home directory via PrivatePath."""
763812
monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser"))
764-
monkeypatch.delenv("NO_COLOR", raising=False)
765-
colors = Colors(ColorMode.ALWAYS)
766813

767814
workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml")
768-
output = (
769-
colors.info("[Loading]")
770-
+ " "
771-
+ colors.highlight(str(PrivatePath(workspace_file)))
772-
)
815+
private_path = str(PrivatePath(workspace_file))
816+
message = f"Loading workspace: myproject ({private_path})"
773817

774-
assert "~/work/project/.tmuxp.yaml" in output
775-
assert "/home/testuser" not in output
818+
assert "~/work/project/.tmuxp.yaml" in message
819+
assert "/home/testuser" not in message

tests/cli/test_ls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def test_ls_json_output(
161161
cli.cli(["ls", "--json"])
162162

163163
output = capsys.readouterr().out
164+
assert "\x1b" not in output, "ANSI escapes must not leak into machine output"
164165
data = json.loads(output)
165166

166167
# JSON output is now an object with workspaces and global_workspace_dirs
@@ -200,6 +201,7 @@ def test_ls_ndjson_output(
200201
cli.cli(["ls", "--ndjson"])
201202

202203
output = capsys.readouterr().out
204+
assert "\x1b" not in output, "ANSI escapes must not leak into machine output"
203205
lines = [line for line in output.strip().split("\n") if line]
204206

205207
assert len(lines) == 2

tests/cli/test_search.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,7 @@ def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None:
830830
formatter.finalize()
831831

832832
captured = capsys.readouterr()
833+
assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output"
833834
data = json.loads(captured.out)
834835
assert len(data) == 1
835836
assert data[0]["name"] == "dev"
@@ -857,6 +858,7 @@ def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> Non
857858
formatter.finalize()
858859

859860
captured = capsys.readouterr()
861+
assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output"
860862
lines = captured.out.strip().split("\n")
861863
# Filter out human-readable lines
862864
json_lines = [line for line in lines if line.startswith("{")]

tests/test_plugin.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
57
import pytest
68

79
from tmuxp.exc import TmuxpPluginException
@@ -95,3 +97,15 @@ def test_libtmux_version_fail_incompatible() -> None:
9597
with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info:
9698
LibtmuxVersionFailIncompatiblePlugin()
9799
assert "libtmux-incompatible-version-fail" in str(exc_info.value)
100+
101+
102+
def test_plugin_version_check_logs_debug(
103+
caplog: pytest.LogCaptureFixture,
104+
) -> None:
105+
"""_version_check() logs DEBUG with plugin name."""
106+
with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"):
107+
AllVersionPassPlugin()
108+
records = [
109+
r for r in caplog.records if r.msg == "checking version constraints for %s"
110+
]
111+
assert len(records) >= 1

tests/test_util.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
import os
7+
import pathlib
58
import sys
69
import typing as t
710

811
import pytest
912

1013
from tmuxp import exc
1114
from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists
12-
from tmuxp.util import get_session, run_before_script
15+
from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script
1316

1417
from .constants import FIXTURE_PATH
1518

@@ -166,3 +169,68 @@ def test_get_session_should_return_first_session_if_no_active_session(
166169
server.new_session(session_name="mysecondsession")
167170

168171
assert get_session(server) == first_session
172+
173+
174+
def test_get_pane_logs_debug_on_failure(
175+
server: Server,
176+
monkeypatch: pytest.MonkeyPatch,
177+
caplog: pytest.LogCaptureFixture,
178+
) -> None:
179+
"""get_pane() logs DEBUG with tmux_pane extra when pane lookup fails."""
180+
session = server.new_session(session_name="test_pane_log")
181+
window = session.active_window
182+
183+
# Make active_pane raise Exception to trigger the logging path
184+
monkeypatch.setattr(
185+
type(window),
186+
"active_pane",
187+
property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))),
188+
)
189+
190+
with (
191+
caplog.at_level(logging.DEBUG, logger="tmuxp.util"),
192+
pytest.raises(exc.PaneNotFound),
193+
):
194+
get_pane(window, current_pane=None)
195+
196+
debug_records = [
197+
r
198+
for r in caplog.records
199+
if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG
200+
]
201+
assert len(debug_records) >= 1
202+
assert debug_records[0].tmux_pane == ""
203+
204+
205+
def test_oh_my_zsh_auto_title_logs_warning(
206+
monkeypatch: pytest.MonkeyPatch,
207+
caplog: pytest.LogCaptureFixture,
208+
tmp_path: t.Any,
209+
) -> None:
210+
"""oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set."""
211+
monkeypatch.setenv("SHELL", "/bin/zsh")
212+
monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False)
213+
214+
# Create fake ~/.oh-my-zsh directory
215+
fake_home = tmp_path / "home"
216+
fake_home.mkdir()
217+
oh_my_zsh_dir = fake_home / ".oh-my-zsh"
218+
oh_my_zsh_dir.mkdir()
219+
monkeypatch.setenv("HOME", str(fake_home))
220+
221+
# Patch os.path.exists to return True for ~/.oh-my-zsh
222+
original_exists = os.path.exists
223+
224+
def patched_exists(path: str) -> bool:
225+
if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()):
226+
return True
227+
return original_exists(path)
228+
229+
monkeypatch.setattr(os.path, "exists", patched_exists)
230+
231+
with caplog.at_level(logging.WARNING, logger="tmuxp.util"):
232+
oh_my_zsh_auto_title()
233+
234+
warning_records = [r for r in caplog.records if r.levelno == logging.WARNING]
235+
assert len(warning_records) >= 1
236+
assert "DISABLE_AUTO_TITLE" in warning_records[0].message

tests/workspace/test_builder.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import functools
6+
import logging
67
import os
78
import pathlib
89
import textwrap
@@ -697,6 +698,7 @@ def test_window_index(
697698

698699
def test_before_script_throw_error_if_retcode_error(
699700
server: Server,
701+
caplog: pytest.LogCaptureFixture,
700702
) -> None:
701703
"""Test tmuxp configuration before_script when command fails."""
702704
config_script_fails = test_utils.read_workspace_file(
@@ -716,12 +718,20 @@ def test_before_script_throw_error_if_retcode_error(
716718
session_name = sess.name
717719
assert session_name is not None
718720

719-
with pytest.raises(exc.BeforeLoadScriptError):
721+
with (
722+
caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"),
723+
pytest.raises(exc.BeforeLoadScriptError),
724+
):
720725
builder.build(session=sess)
721726

722727
result = server.has_session(session_name)
723728
assert not result, "Kills session if before_script exits with errcode"
724729

730+
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
731+
assert len(error_records) >= 1
732+
assert error_records[0].msg == "before script failed"
733+
assert hasattr(error_records[0], "tmux_session")
734+
725735

726736
def test_before_script_throw_error_if_file_not_exists(
727737
server: Server,
@@ -1681,3 +1691,80 @@ def counting_layout(self: Window, layout: str | None = None) -> Window:
16811691
builder.build()
16821692
# 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6
16831693
assert call_count == 3
1694+
1695+
1696+
def test_builder_logs_session_created(
1697+
server: Server,
1698+
caplog: pytest.LogCaptureFixture,
1699+
) -> None:
1700+
"""WorkspaceBuilder.build() logs INFO with tmux_session extra."""
1701+
workspace = {
1702+
"session_name": "test_log_session",
1703+
"windows": [
1704+
{
1705+
"window_name": "main",
1706+
"panes": [
1707+
{"shell_command": []},
1708+
],
1709+
},
1710+
],
1711+
}
1712+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1713+
1714+
with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"):
1715+
builder.build()
1716+
1717+
session_logs = [
1718+
r
1719+
for r in caplog.records
1720+
if hasattr(r, "tmux_session") and r.msg == "session created"
1721+
]
1722+
assert len(session_logs) >= 1
1723+
assert session_logs[0].tmux_session == "test_log_session"
1724+
1725+
# Verify workspace built log
1726+
built_logs = [r for r in caplog.records if r.msg == "workspace built"]
1727+
assert len(built_logs) >= 1
1728+
1729+
builder.session.kill()
1730+
1731+
1732+
def test_builder_logs_window_and_pane_creation(
1733+
server: Server,
1734+
caplog: pytest.LogCaptureFixture,
1735+
) -> None:
1736+
"""WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra."""
1737+
workspace = {
1738+
"session_name": "test_log_wp",
1739+
"windows": [
1740+
{
1741+
"window_name": "editor",
1742+
"panes": [
1743+
{"shell_command": [{"cmd": "echo hello"}]},
1744+
{"shell_command": []},
1745+
],
1746+
},
1747+
],
1748+
}
1749+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1750+
1751+
with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"):
1752+
builder.build()
1753+
1754+
window_logs = [
1755+
r
1756+
for r in caplog.records
1757+
if hasattr(r, "tmux_window") and r.msg == "window created"
1758+
]
1759+
assert len(window_logs) >= 1
1760+
assert window_logs[0].tmux_window == "editor"
1761+
1762+
pane_logs = [
1763+
r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created"
1764+
]
1765+
assert len(pane_logs) >= 1
1766+
1767+
cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"]
1768+
assert len(cmd_logs) >= 1
1769+
1770+
builder.session.kill()

0 commit comments

Comments
 (0)