diff --git a/.gitignore b/.gitignore index 9dadb843bd..0fbfc1838b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ docs/_static/css/fonts.css **/CLAUDE.local.md **/CLAUDE.*.md **/.claude/settings.local.json + +# pytest-optimizer durable state (profiling/benchmarks) +.pytest-optimizer/ diff --git a/conftest.py b/conftest.py index 1f0583439e..e6cd7b8f2b 100644 --- a/conftest.py +++ b/conftest.py @@ -44,6 +44,23 @@ def _pin_test_shell_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SHELL", "/bin/sh") +@pytest.fixture(autouse=True) +def _pin_test_color_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Neutralize ``FORCE_COLOR`` / ``NO_COLOR`` for every test. + + CLI color is decided live from these env vars (see + :class:`tmuxp._internal.colors.Colors`). A contributor who exports + ``FORCE_COLOR`` in their shell would otherwise get ANSI-wrapped help + text, breaking the plain-text example assertions in + ``tests/cli/test_help_examples.py`` and similar. Clearing both here gives + every test a deterministic, auto-detected baseline; tests that exercise + color set the vars explicitly, and ``monkeypatch`` restores the + contributor's values at teardown. + """ + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + + @pytest.fixture(autouse=USING_ZSH, scope="session") def zshrc(user_path: pathlib.Path) -> pathlib.Path | None: """Quiets ZSH default message. diff --git a/justfile b/justfile index 3c2fd193da..5b3b09c225 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,11 @@ default: test *args: uv run py.test {{ args }} +# Run tests in parallel with pytest-xdist (capped workers; -n auto can thrash) +[group: 'test'] +test-parallel *args: + uv run py.test -n 8 {{ args }} + # Run tests then start continuous testing with pytest-watcher [group: 'test'] start: diff --git a/pyproject.toml b/pyproject.toml index 2c7ed93b32..65348327a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ dev = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-xdist", # Coverage "codecov", "coverage", @@ -95,6 +96,7 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-xdist", ] coverage =[ "codecov", @@ -241,7 +243,7 @@ convention = "numpy" "docs/_ext/aafig.py" = ["PTH"] [tool.pytest.ini_options] -addopts = "--reruns=0 --tb=short --no-header --showlocals --doctest-modules" +addopts = "--reruns=0 --tb=short --no-header --showlocals --doctest-modules --strict-markers" doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" testpaths = [ "src/tmuxp", diff --git a/tests/workspace/test_finders_local.py b/tests/workspace/test_finders_local.py index 5482df3abc..c53a651eb4 100644 --- a/tests/workspace/test_finders_local.py +++ b/tests/workspace/test_finders_local.py @@ -135,151 +135,136 @@ def test_find_local_workspace_files( assert result_relative == expected_paths -class TestFindLocalWorkspaceEdgeCases: - """Edge case tests for local workspace discovery.""" - - def test_at_home_directory( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test behavior when starting at home directory.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (home / ".tmuxp.yaml").write_text("session_name: home\n") - - result = find_local_workspace_files(home, stop_at_home=True) - - assert len(result) == 1 - assert result[0] == home / ".tmuxp.yaml" - - def test_at_filesystem_root( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test traversal stops at filesystem root.""" - # This test verifies no infinite loop at root - result = find_local_workspace_files(pathlib.Path("/"), stop_at_home=False) - # Should complete without error; result depends on system state - assert isinstance(result, list) - - def test_yaml_precedence_over_json( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test .yaml is preferred when multiple formats exist.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - # Create both formats - (project / ".tmuxp.yaml").write_text("session_name: yaml\n") - (project / ".tmuxp.json").write_text('{"session_name": "json"}') - - result = find_local_workspace_files(project, stop_at_home=True) - - assert len(result) == 1 - assert result[0].name == ".tmuxp.yaml" - - def test_yml_precedence_over_json( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test .yml is preferred when .yaml not present but .json exists.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - # Create yml and json (no yaml) - (project / ".tmuxp.yml").write_text("session_name: yml\n") - (project / ".tmuxp.json").write_text('{"session_name": "json"}') - - result = find_local_workspace_files(project, stop_at_home=True) - - assert len(result) == 1 - assert result[0].name == ".tmuxp.yml" - - def test_stop_at_home_false_continues_past_home( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test stop_at_home=False continues traversal past home.""" - # Create structure: /grandparent/home/project - grandparent = tmp_path / "grandparent" - home = grandparent / "home" - project = home / "project" - project.mkdir(parents=True) - - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - # Put config in grandparent (above home) - (grandparent / ".tmuxp.yaml").write_text("session_name: grandparent\n") - (project / ".tmuxp.yaml").write_text("session_name: project\n") - - # With stop_at_home=True, should only find project config - result_stop = find_local_workspace_files(project, stop_at_home=True) - assert len(result_stop) == 1 - assert "project" in str(result_stop[0]) - - # With stop_at_home=False, should find both - result_continue = find_local_workspace_files(project, stop_at_home=False) - assert len(result_continue) >= 2 - - def test_default_start_dir_uses_cwd( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test that None start_dir uses current working directory.""" - home = tmp_path / "home" - project = home / "project" - project.mkdir(parents=True) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - monkeypatch.chdir(project) - - (project / ".tmuxp.yaml").write_text("session_name: cwd\n") - - result = find_local_workspace_files(None, stop_at_home=True) - - assert len(result) == 1 - assert result[0] == project / ".tmuxp.yaml" - - def test_symlinked_directory( - self, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Test behavior with symlinked directories.""" - home = tmp_path / "home" - real_project = home / "real_project" - real_project.mkdir(parents=True) - symlink_project = home / "symlink_project" - symlink_project.symlink_to(real_project) - monkeypatch.setattr(pathlib.Path, "home", lambda: home) - - (real_project / ".tmuxp.yaml").write_text("session_name: test\n") - - result = find_local_workspace_files(symlink_project, stop_at_home=True) - - assert len(result) == 1 - - -class TestLocalWorkspaceFilesConstant: - """Tests for LOCAL_WORKSPACE_FILES constant.""" - - def test_constant_order(self) -> None: - """Verify LOCAL_WORKSPACE_FILES has correct order (yaml, yml, json).""" - assert LOCAL_WORKSPACE_FILES == [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] - - def test_constant_is_list(self) -> None: - """Verify LOCAL_WORKSPACE_FILES is a list.""" - assert isinstance(LOCAL_WORKSPACE_FILES, list) - assert len(LOCAL_WORKSPACE_FILES) == 3 +def test_at_home_directory( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test behavior when starting at home directory.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (home / ".tmuxp.yaml").write_text("session_name: home\n") + + result = find_local_workspace_files(home, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == home / ".tmuxp.yaml" + + +def test_at_filesystem_root() -> None: + """Test traversal stops at filesystem root.""" + # This test verifies no infinite loop at root + result = find_local_workspace_files(pathlib.Path("/"), stop_at_home=False) + # Should complete without error; result depends on system state + assert isinstance(result, list) + + +def test_yaml_precedence_over_json( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test .yaml is preferred when multiple formats exist.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create both formats + (project / ".tmuxp.yaml").write_text("session_name: yaml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yaml" + + +def test_yml_precedence_over_json( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test .yml is preferred when .yaml not present but .json exists.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Create yml and json (no yaml) + (project / ".tmuxp.yml").write_text("session_name: yml\n") + (project / ".tmuxp.json").write_text('{"session_name": "json"}') + + result = find_local_workspace_files(project, stop_at_home=True) + + assert len(result) == 1 + assert result[0].name == ".tmuxp.yml" + + +def test_stop_at_home_false_continues_past_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test stop_at_home=False continues traversal past home.""" + # Create structure: /grandparent/home/project + grandparent = tmp_path / "grandparent" + home = grandparent / "home" + project = home / "project" + project.mkdir(parents=True) + + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + # Put config in grandparent (above home) + (grandparent / ".tmuxp.yaml").write_text("session_name: grandparent\n") + (project / ".tmuxp.yaml").write_text("session_name: project\n") + + # With stop_at_home=True, should only find project config + result_stop = find_local_workspace_files(project, stop_at_home=True) + assert len(result_stop) == 1 + assert "project" in str(result_stop[0]) + + # With stop_at_home=False, finds both, plus any configs above home + result_continue = find_local_workspace_files(project, stop_at_home=False) + assert len(result_continue) >= 2 + + +def test_default_start_dir_uses_cwd( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that None start_dir uses current working directory.""" + home = tmp_path / "home" + project = home / "project" + project.mkdir(parents=True) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + monkeypatch.chdir(project) + + (project / ".tmuxp.yaml").write_text("session_name: cwd\n") + + result = find_local_workspace_files(None, stop_at_home=True) + + assert len(result) == 1 + assert result[0] == project / ".tmuxp.yaml" + + +def test_symlinked_directory( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test behavior with symlinked directories.""" + home = tmp_path / "home" + real_project = home / "real_project" + real_project.mkdir(parents=True) + symlink_project = home / "symlink_project" + symlink_project.symlink_to(real_project) + monkeypatch.setattr(pathlib.Path, "home", lambda: home) + + (real_project / ".tmuxp.yaml").write_text("session_name: test\n") + + result = find_local_workspace_files(symlink_project, stop_at_home=True) + + assert len(result) == 1 + + +def test_local_workspace_files_constant_order() -> None: + """Verify LOCAL_WORKSPACE_FILES has correct order (yaml, yml, json).""" + assert LOCAL_WORKSPACE_FILES == [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"] diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py index d42386ecef..883a322276 100644 --- a/tests/workspace/test_freezer.py +++ b/tests/workspace/test_freezer.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import time import typing import pytest @@ -31,9 +30,6 @@ def test_freeze_config(session: Session) -> None: builder.build(session=session) assert session == builder.session - time.sleep(0.50) - - session = session new_config = freezer.freeze(session) validation.validate_schema(new_config) @@ -122,8 +118,6 @@ def test_freeze_logs_debug( builder = WorkspaceBuilder(session_config=session_config, server=session.server) builder.build(session=session) - time.sleep(0.50) - with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): freezer.freeze(session) diff --git a/uv.lock b/uv.lock index 71bb1214c4..4e4807e037 100644 --- a/uv.lock +++ b/uv.lock @@ -409,6 +409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "gp-furo-theme" version = "0.0.1a31" @@ -1074,6 +1083,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1634,6 +1656,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1666,6 +1689,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -1693,6 +1717,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a31" }, @@ -1723,6 +1748,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, ] [[package]]