From 7b068de4462a1a689a7a7349174da6f46bf07a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 15:34:15 +0000 Subject: [PATCH 1/4] Add install process requirements tests Add automated tests verifying critical requirements for the install process: - REQ-001: Install MUST NOT modify local (user home) Claude settings - REQ-002: Install MUST be idempotent for project settings These are REQUIREMENTS tests that protect user data integrity and ensure installation reliability. They should not be altered - if tests fail, fix the implementation, not the tests. Test coverage: - Local settings protection: verifies ~/.claude/settings.json is untouched - Project settings only: verifies only .claude/settings.json in project is modified - Idempotency: verifies second install produces identical settings - No duplicate hooks: verifies multiple installs don't create duplicates - Nth install stability: verifies settings are stable across many installs --- .../integration/test_install_requirements.py | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 tests/integration/test_install_requirements.py diff --git a/tests/integration/test_install_requirements.py b/tests/integration/test_install_requirements.py new file mode 100644 index 00000000..d717cdca --- /dev/null +++ b/tests/integration/test_install_requirements.py @@ -0,0 +1,341 @@ +""" +================================================================================ + REQUIREMENTS TESTS - DO NOT MODIFY +================================================================================ + +These tests verify CRITICAL REQUIREMENTS for the DeepWork install process. +They ensure the install command behaves correctly with respect to: + +1. LOCAL vs PROJECT settings isolation +2. Idempotency of project settings + +WARNING: These tests represent contractual requirements for the install process. +Modifying these tests may violate user expectations and could cause data loss +or unexpected behavior. If a test fails, fix the IMPLEMENTATION, not the test. + +Requirements tested: + - REQ-001: Install MUST NOT modify local (user home) Claude settings + - REQ-002: Install MUST be idempotent for project settings + +================================================================================ +""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from deepwork.cli.main import cli + + +# ============================================================================= +# REQ-001: Install MUST NOT modify local (user home) Claude settings +# ============================================================================= +# +# Claude Code has two levels of settings: +# - LOCAL settings: ~/.claude/settings.json (user's global settings) +# - PROJECT settings: /.claude/settings.json (project-specific) +# +# DeepWork install MUST ONLY modify project settings and NEVER touch +# the user's local settings, which may contain personal configurations, +# API keys, or other sensitive data. +# +# DO NOT MODIFY THIS TEST - It protects user data integrity. +# ============================================================================= + + +class TestLocalSettingsProtection: + """ + REQUIREMENTS TEST: Verify install does not modify local Claude settings. + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify that the install process respects the boundary between + project-level and user-level settings. Modifying these tests could result + in DeepWork overwriting user's personal Claude configurations. + """ + + def test_install_does_not_modify_local_claude_settings( + self, mock_claude_project: Path, tmp_path: Path + ) -> None: + """ + REQ-001: Install MUST NOT modify local (home directory) Claude settings. + + This test creates a mock local settings file and verifies that the + DeepWork install process does not modify it in any way. + + DO NOT MODIFY THIS TEST. + """ + # Create a mock local Claude settings directory in tmp_path + mock_home = tmp_path / "mock_home" + mock_local_claude_dir = mock_home / ".claude" + mock_local_claude_dir.mkdir(parents=True) + + # Create local settings with known content + local_settings_file = mock_local_claude_dir / "settings.json" + original_local_settings = { + "user_preference": "do_not_change", + "api_key_encrypted": "sensitive_data_here", + "custom_config": {"setting1": True, "setting2": "value"}, + } + local_settings_file.write_text(json.dumps(original_local_settings, indent=2)) + + # Record the original file modification time + original_mtime = local_settings_file.stat().st_mtime + + # Run install with mocked home directory + runner = CliRunner() + with patch.dict("os.environ", {"HOME": str(mock_home)}): + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + # Verify install succeeded + assert result.exit_code == 0, f"Install failed: {result.output}" + + # CRITICAL: Verify local settings were NOT modified + assert local_settings_file.exists(), "Local settings file should still exist" + + # Check content is unchanged + current_local_settings = json.loads(local_settings_file.read_text()) + assert current_local_settings == original_local_settings, ( + "LOCAL SETTINGS WERE MODIFIED! " + "Install MUST NOT touch user's home directory Claude settings. " + f"Expected: {original_local_settings}, Got: {current_local_settings}" + ) + + # Check modification time is unchanged + current_mtime = local_settings_file.stat().st_mtime + assert current_mtime == original_mtime, ( + "LOCAL SETTINGS FILE WAS TOUCHED! " + "Install MUST NOT access user's home directory Claude settings." + ) + + def test_install_only_modifies_project_settings( + self, mock_claude_project: Path, tmp_path: Path + ) -> None: + """ + REQ-001 (corollary): Install MUST modify only project-level settings. + + Verifies that the install process correctly modifies project settings + while leaving local settings untouched. + + DO NOT MODIFY THIS TEST. + """ + # Create mock local Claude settings + mock_home = tmp_path / "mock_home" + mock_local_claude_dir = mock_home / ".claude" + mock_local_claude_dir.mkdir(parents=True) + + local_settings_file = mock_local_claude_dir / "settings.json" + original_local_content = '{"local": "unchanged"}' + local_settings_file.write_text(original_local_content) + + # Record original project settings + project_settings_file = mock_claude_project / ".claude" / "settings.json" + original_project_content = project_settings_file.read_text() + + # Run install + runner = CliRunner() + with patch.dict("os.environ", {"HOME": str(mock_home)}): + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + # Verify LOCAL settings unchanged + assert local_settings_file.read_text() == original_local_content, ( + "Local settings were modified! Install must only modify project settings." + ) + + # Verify PROJECT settings were modified (hooks should be added) + current_project_content = project_settings_file.read_text() + project_settings = json.loads(current_project_content) + + # The install should have added hooks to project settings + assert "hooks" in project_settings, ( + "Project settings should have hooks after install" + ) + + +# ============================================================================= +# REQ-002: Install MUST be idempotent for project settings +# ============================================================================= +# +# Running `deepwork install` multiple times on the same project MUST produce +# identical results. The second and subsequent installs should not: +# - Add duplicate entries +# - Modify timestamps unnecessarily +# - Change the structure or content of settings +# +# This ensures that users can safely re-run install without side effects, +# which is important for CI/CD pipelines, onboarding scripts, and +# troubleshooting scenarios. +# +# DO NOT MODIFY THIS TEST - It ensures installation reliability. +# ============================================================================= + + +class TestProjectSettingsIdempotency: + """ + REQUIREMENTS TEST: Verify install is idempotent for project settings. + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify that running install multiple times produces identical + results. This is critical for: + - CI/CD reliability + - Safe re-installation + - Troubleshooting without side effects + """ + + def test_project_settings_unchanged_on_second_install( + self, mock_claude_project: Path + ) -> None: + """ + REQ-002: Second install MUST NOT change project settings. + + Running install twice should produce identical settings.json content. + The second install should be a no-op for settings. + + DO NOT MODIFY THIS TEST. + """ + runner = CliRunner() + + # First install + result1 = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + assert result1.exit_code == 0, f"First install failed: {result1.output}" + + # Capture settings after first install + settings_file = mock_claude_project / ".claude" / "settings.json" + settings_after_first_install = settings_file.read_text() + settings_json_first = json.loads(settings_after_first_install) + + # Second install + result2 = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + assert result2.exit_code == 0, f"Second install failed: {result2.output}" + + # Capture settings after second install + settings_after_second_install = settings_file.read_text() + settings_json_second = json.loads(settings_after_second_install) + + # CRITICAL: Settings must be identical + assert settings_json_first == settings_json_second, ( + "PROJECT SETTINGS CHANGED ON SECOND INSTALL! " + "Install MUST be idempotent. " + f"After first: {json.dumps(settings_json_first, indent=2)}\n" + f"After second: {json.dumps(settings_json_second, indent=2)}" + ) + + def test_no_duplicate_hooks_on_multiple_installs( + self, mock_claude_project: Path + ) -> None: + """ + REQ-002 (corollary): Multiple installs MUST NOT create duplicate hooks. + + This specifically tests that hooks are not duplicated, which would + cause performance issues and unexpected behavior. + + DO NOT MODIFY THIS TEST. + """ + runner = CliRunner() + + # Run install three times + for i in range(3): + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"Install #{i+1} failed: {result.output}" + + # Load final settings + settings_file = mock_claude_project / ".claude" / "settings.json" + settings = json.loads(settings_file.read_text()) + + # Verify no duplicate hooks + if "hooks" in settings: + for event_name, hooks_list in settings["hooks"].items(): + # Extract all hook commands for duplicate detection + commands = [] + for hook_entry in hooks_list: + for hook in hook_entry.get("hooks", []): + if "command" in hook: + commands.append(hook["command"]) + + # Check for duplicates + unique_commands = set(commands) + assert len(commands) == len(unique_commands), ( + f"DUPLICATE HOOKS DETECTED for event '{event_name}'! " + f"Install MUST be idempotent. " + f"Commands: {commands}" + ) + + def test_third_install_identical_to_first( + self, mock_claude_project: Path + ) -> None: + """ + REQ-002 (extended): Nth install MUST produce same result as first. + + This tests the general idempotency property across multiple runs. + + DO NOT MODIFY THIS TEST. + """ + runner = CliRunner() + + # First install + runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + settings_file = mock_claude_project / ".claude" / "settings.json" + settings_after_first = json.loads(settings_file.read_text()) + + # Run multiple more installs + for _ in range(5): + runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(mock_claude_project)], + catch_exceptions=False, + ) + + # Final state should match first install + settings_after_many = json.loads(settings_file.read_text()) + + assert settings_after_first == settings_after_many, ( + "SETTINGS DIVERGED AFTER MULTIPLE INSTALLS! " + "Install must be idempotent regardless of how many times it runs." + ) + + +# ============================================================================= +# FIXTURE EXTENSIONS +# ============================================================================= +# Additional fixtures needed for these requirement tests + + +@pytest.fixture +def tmp_path(temp_dir: Path) -> Path: + """Alias for temp_dir to match pytest naming convention.""" + return temp_dir From 3ddb673e514d588a4ac5c21debf2eb2888f3b771 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 17:15:41 +0000 Subject: [PATCH 2/4] Strengthen install requirements tests with meaningful idempotency checks Update the idempotency tests to verify that: - First install MUST actually modify settings (add hooks) - Only then verify subsequent installs don't change anything This prevents false positives where a broken install that does nothing would trivially pass idempotency tests. Also includes lint fixes. --- .../integration/test_install_requirements.py | 111 +++++++++++------- 1 file changed, 71 insertions(+), 40 deletions(-) diff --git a/tests/integration/test_install_requirements.py b/tests/integration/test_install_requirements.py index d717cdca..313fa0bd 100644 --- a/tests/integration/test_install_requirements.py +++ b/tests/integration/test_install_requirements.py @@ -29,7 +29,6 @@ from deepwork.cli.main import cli - # ============================================================================= # REQ-001: Install MUST NOT modify local (user home) Claude settings # ============================================================================= @@ -137,9 +136,8 @@ def test_install_only_modifies_project_settings( original_local_content = '{"local": "unchanged"}' local_settings_file.write_text(original_local_content) - # Record original project settings + # Record project settings path for later verification project_settings_file = mock_claude_project / ".claude" / "settings.json" - original_project_content = project_settings_file.read_text() # Run install runner = CliRunner() @@ -162,9 +160,7 @@ def test_install_only_modifies_project_settings( project_settings = json.loads(current_project_content) # The install should have added hooks to project settings - assert "hooks" in project_settings, ( - "Project settings should have hooks after install" - ) + assert "hooks" in project_settings, "Project settings should have hooks after install" # ============================================================================= @@ -200,19 +196,22 @@ class TestProjectSettingsIdempotency: - Troubleshooting without side effects """ - def test_project_settings_unchanged_on_second_install( - self, mock_claude_project: Path - ) -> None: + def test_project_settings_unchanged_on_second_install(self, mock_claude_project: Path) -> None: """ REQ-002: Second install MUST NOT change project settings. Running install twice should produce identical settings.json content. - The second install should be a no-op for settings. + The first install MUST modify settings (add hooks), and the second + install should be a no-op for settings. DO NOT MODIFY THIS TEST. """ runner = CliRunner() + # Capture settings BEFORE first install + settings_file = mock_claude_project / ".claude" / "settings.json" + settings_before_install = json.loads(settings_file.read_text()) + # First install result1 = runner.invoke( cli, @@ -222,10 +221,23 @@ def test_project_settings_unchanged_on_second_install( assert result1.exit_code == 0, f"First install failed: {result1.output}" # Capture settings after first install - settings_file = mock_claude_project / ".claude" / "settings.json" settings_after_first_install = settings_file.read_text() settings_json_first = json.loads(settings_after_first_install) + # CRITICAL: First install MUST actually modify settings (add hooks) + # This ensures the test is meaningful - if install does nothing, + # idempotency would trivially pass but the test would be useless. + assert "hooks" in settings_json_first, ( + "FIRST INSTALL DID NOT ADD HOOKS! " + "Install must add hooks to project settings. " + "This test requires install to actually modify settings to verify idempotency." + ) + assert settings_json_first != settings_before_install, ( + "FIRST INSTALL DID NOT MODIFY SETTINGS! " + "Install must modify project settings on first run. " + "This test requires install to actually do something to verify idempotency." + ) + # Second install result2 = runner.invoke( cli, @@ -238,7 +250,7 @@ def test_project_settings_unchanged_on_second_install( settings_after_second_install = settings_file.read_text() settings_json_second = json.loads(settings_after_second_install) - # CRITICAL: Settings must be identical + # CRITICAL: Settings must be identical after second install assert settings_json_first == settings_json_second, ( "PROJECT SETTINGS CHANGED ON SECOND INSTALL! " "Install MUST be idempotent. " @@ -246,9 +258,7 @@ def test_project_settings_unchanged_on_second_install( f"After second: {json.dumps(settings_json_second, indent=2)}" ) - def test_no_duplicate_hooks_on_multiple_installs( - self, mock_claude_project: Path - ) -> None: + def test_no_duplicate_hooks_on_multiple_installs(self, mock_claude_project: Path) -> None: """ REQ-002 (corollary): Multiple installs MUST NOT create duplicate hooks. @@ -266,59 +276,80 @@ def test_no_duplicate_hooks_on_multiple_installs( ["install", "--platform", "claude", "--path", str(mock_claude_project)], catch_exceptions=False, ) - assert result.exit_code == 0, f"Install #{i+1} failed: {result.output}" + assert result.exit_code == 0, f"Install #{i + 1} failed: {result.output}" # Load final settings settings_file = mock_claude_project / ".claude" / "settings.json" settings = json.loads(settings_file.read_text()) + # CRITICAL: Hooks must exist for this test to be meaningful + assert "hooks" in settings, ( + "NO HOOKS FOUND AFTER INSTALL! " + "Install must add hooks to project settings. " + "This test requires hooks to exist to verify no duplicates are created." + ) + # Verify no duplicate hooks - if "hooks" in settings: - for event_name, hooks_list in settings["hooks"].items(): - # Extract all hook commands for duplicate detection - commands = [] - for hook_entry in hooks_list: - for hook in hook_entry.get("hooks", []): - if "command" in hook: - commands.append(hook["command"]) - - # Check for duplicates - unique_commands = set(commands) - assert len(commands) == len(unique_commands), ( - f"DUPLICATE HOOKS DETECTED for event '{event_name}'! " - f"Install MUST be idempotent. " - f"Commands: {commands}" - ) - - def test_third_install_identical_to_first( - self, mock_claude_project: Path - ) -> None: + for event_name, hooks_list in settings["hooks"].items(): + # Extract all hook commands for duplicate detection + commands = [] + for hook_entry in hooks_list: + for hook in hook_entry.get("hooks", []): + if "command" in hook: + commands.append(hook["command"]) + + # Check for duplicates + unique_commands = set(commands) + assert len(commands) == len(unique_commands), ( + f"DUPLICATE HOOKS DETECTED for event '{event_name}'! " + f"Install MUST be idempotent. " + f"Commands: {commands}" + ) + + def test_third_install_identical_to_first(self, mock_claude_project: Path) -> None: """ REQ-002 (extended): Nth install MUST produce same result as first. This tests the general idempotency property across multiple runs. + The first install MUST modify settings, and all subsequent installs + MUST produce identical results. DO NOT MODIFY THIS TEST. """ runner = CliRunner() + # Capture settings BEFORE any install + settings_file = mock_claude_project / ".claude" / "settings.json" + settings_before = json.loads(settings_file.read_text()) + # First install - runner.invoke( + result = runner.invoke( cli, ["install", "--platform", "claude", "--path", str(mock_claude_project)], catch_exceptions=False, ) + assert result.exit_code == 0, f"First install failed: {result.output}" - settings_file = mock_claude_project / ".claude" / "settings.json" settings_after_first = json.loads(settings_file.read_text()) + # CRITICAL: First install MUST actually modify settings + assert "hooks" in settings_after_first, ( + "FIRST INSTALL DID NOT ADD HOOKS! " + "Install must add hooks to verify idempotency is meaningful." + ) + assert settings_after_first != settings_before, ( + "FIRST INSTALL DID NOT MODIFY SETTINGS! " + "Install must modify settings on first run to verify idempotency." + ) + # Run multiple more installs - for _ in range(5): - runner.invoke( + for i in range(5): + result = runner.invoke( cli, ["install", "--platform", "claude", "--path", str(mock_claude_project)], catch_exceptions=False, ) + assert result.exit_code == 0, f"Install #{i + 2} failed: {result.output}" # Final state should match first install settings_after_many = json.loads(settings_file.read_text()) From 9aafc5beeb7607b6fc3b411048b498ff2004d93b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 17:22:49 +0000 Subject: [PATCH 3/4] Refactor install requirements tests with helper functions Extract common patterns into reusable helpers: - run_install(): CLI invocation wrapper - get_project_settings(): Read and parse settings.json - assert_install_added_hooks(): Verify first install modified settings Reduces code by 36 lines while maintaining test clarity. --- .../integration/test_install_requirements.py | 188 +++++++----------- 1 file changed, 76 insertions(+), 112 deletions(-) diff --git a/tests/integration/test_install_requirements.py b/tests/integration/test_install_requirements.py index 313fa0bd..cb2ca6db 100644 --- a/tests/integration/test_install_requirements.py +++ b/tests/integration/test_install_requirements.py @@ -29,6 +29,51 @@ from deepwork.cli.main import cli +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= +# These helpers reduce repetition while keeping individual tests readable. +# The helpers themselves are simple and should not mask test intent. + + +def run_install(project_path: Path) -> None: + """Run deepwork install for Claude on the given project path. + + Raises AssertionError if install fails. + """ + runner = CliRunner() + result = runner.invoke( + cli, + ["install", "--platform", "claude", "--path", str(project_path)], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"Install failed: {result.output}" + + +def get_project_settings(project_path: Path) -> dict: + """Read and parse the project's Claude settings.json.""" + settings_file = project_path / ".claude" / "settings.json" + return json.loads(settings_file.read_text()) + + +def assert_install_added_hooks(settings_before: dict, settings_after: dict) -> None: + """Assert that install actually modified settings by adding hooks. + + This ensures idempotency tests are meaningful - if install does nothing, + idempotency would trivially pass but the test would be useless. + """ + assert "hooks" in settings_after, ( + "FIRST INSTALL DID NOT ADD HOOKS! " + "Install must add hooks to project settings. " + "This test requires install to actually modify settings to verify idempotency." + ) + assert settings_after != settings_before, ( + "FIRST INSTALL DID NOT MODIFY SETTINGS! " + "Install must modify project settings on first run. " + "This test requires install to actually do something to verify idempotency." + ) + + # ============================================================================= # REQ-001: Install MUST NOT modify local (user home) Claude settings # ============================================================================= @@ -87,16 +132,8 @@ def test_install_does_not_modify_local_claude_settings( original_mtime = local_settings_file.stat().st_mtime # Run install with mocked home directory - runner = CliRunner() with patch.dict("os.environ", {"HOME": str(mock_home)}): - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - - # Verify install succeeded - assert result.exit_code == 0, f"Install failed: {result.output}" + run_install(mock_claude_project) # CRITICAL: Verify local settings were NOT modified assert local_settings_file.exists(), "Local settings file should still exist" @@ -136,19 +173,9 @@ def test_install_only_modifies_project_settings( original_local_content = '{"local": "unchanged"}' local_settings_file.write_text(original_local_content) - # Record project settings path for later verification - project_settings_file = mock_claude_project / ".claude" / "settings.json" - # Run install - runner = CliRunner() with patch.dict("os.environ", {"HOME": str(mock_home)}): - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - - assert result.exit_code == 0 + run_install(mock_claude_project) # Verify LOCAL settings unchanged assert local_settings_file.read_text() == original_local_content, ( @@ -156,10 +183,7 @@ def test_install_only_modifies_project_settings( ) # Verify PROJECT settings were modified (hooks should be added) - current_project_content = project_settings_file.read_text() - project_settings = json.loads(current_project_content) - - # The install should have added hooks to project settings + project_settings = get_project_settings(mock_claude_project) assert "hooks" in project_settings, "Project settings should have hooks after install" @@ -206,56 +230,26 @@ def test_project_settings_unchanged_on_second_install(self, mock_claude_project: DO NOT MODIFY THIS TEST. """ - runner = CliRunner() - # Capture settings BEFORE first install - settings_file = mock_claude_project / ".claude" / "settings.json" - settings_before_install = json.loads(settings_file.read_text()) + settings_before = get_project_settings(mock_claude_project) # First install - result1 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result1.exit_code == 0, f"First install failed: {result1.output}" + run_install(mock_claude_project) + settings_after_first = get_project_settings(mock_claude_project) - # Capture settings after first install - settings_after_first_install = settings_file.read_text() - settings_json_first = json.loads(settings_after_first_install) - - # CRITICAL: First install MUST actually modify settings (add hooks) - # This ensures the test is meaningful - if install does nothing, - # idempotency would trivially pass but the test would be useless. - assert "hooks" in settings_json_first, ( - "FIRST INSTALL DID NOT ADD HOOKS! " - "Install must add hooks to project settings. " - "This test requires install to actually modify settings to verify idempotency." - ) - assert settings_json_first != settings_before_install, ( - "FIRST INSTALL DID NOT MODIFY SETTINGS! " - "Install must modify project settings on first run. " - "This test requires install to actually do something to verify idempotency." - ) + # CRITICAL: First install MUST actually modify settings + assert_install_added_hooks(settings_before, settings_after_first) # Second install - result2 = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result2.exit_code == 0, f"Second install failed: {result2.output}" - - # Capture settings after second install - settings_after_second_install = settings_file.read_text() - settings_json_second = json.loads(settings_after_second_install) + run_install(mock_claude_project) + settings_after_second = get_project_settings(mock_claude_project) # CRITICAL: Settings must be identical after second install - assert settings_json_first == settings_json_second, ( + assert settings_after_first == settings_after_second, ( "PROJECT SETTINGS CHANGED ON SECOND INSTALL! " "Install MUST be idempotent. " - f"After first: {json.dumps(settings_json_first, indent=2)}\n" - f"After second: {json.dumps(settings_json_second, indent=2)}" + f"After first: {json.dumps(settings_after_first, indent=2)}\n" + f"After second: {json.dumps(settings_after_second, indent=2)}" ) def test_no_duplicate_hooks_on_multiple_installs(self, mock_claude_project: Path) -> None: @@ -267,20 +261,12 @@ def test_no_duplicate_hooks_on_multiple_installs(self, mock_claude_project: Path DO NOT MODIFY THIS TEST. """ - runner = CliRunner() - # Run install three times - for i in range(3): - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result.exit_code == 0, f"Install #{i + 1} failed: {result.output}" + for _ in range(3): + run_install(mock_claude_project) # Load final settings - settings_file = mock_claude_project / ".claude" / "settings.json" - settings = json.loads(settings_file.read_text()) + settings = get_project_settings(mock_claude_project) # CRITICAL: Hooks must exist for this test to be meaningful assert "hooks" in settings, ( @@ -292,18 +278,17 @@ def test_no_duplicate_hooks_on_multiple_installs(self, mock_claude_project: Path # Verify no duplicate hooks for event_name, hooks_list in settings["hooks"].items(): # Extract all hook commands for duplicate detection - commands = [] - for hook_entry in hooks_list: - for hook in hook_entry.get("hooks", []): - if "command" in hook: - commands.append(hook["command"]) + commands = [ + hook["command"] + for hook_entry in hooks_list + for hook in hook_entry.get("hooks", []) + if "command" in hook + ] # Check for duplicates - unique_commands = set(commands) - assert len(commands) == len(unique_commands), ( + assert len(commands) == len(set(commands)), ( f"DUPLICATE HOOKS DETECTED for event '{event_name}'! " - f"Install MUST be idempotent. " - f"Commands: {commands}" + f"Install MUST be idempotent. Commands: {commands}" ) def test_third_install_identical_to_first(self, mock_claude_project: Path) -> None: @@ -316,43 +301,22 @@ def test_third_install_identical_to_first(self, mock_claude_project: Path) -> No DO NOT MODIFY THIS TEST. """ - runner = CliRunner() - # Capture settings BEFORE any install - settings_file = mock_claude_project / ".claude" / "settings.json" - settings_before = json.loads(settings_file.read_text()) + settings_before = get_project_settings(mock_claude_project) # First install - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result.exit_code == 0, f"First install failed: {result.output}" - - settings_after_first = json.loads(settings_file.read_text()) + run_install(mock_claude_project) + settings_after_first = get_project_settings(mock_claude_project) # CRITICAL: First install MUST actually modify settings - assert "hooks" in settings_after_first, ( - "FIRST INSTALL DID NOT ADD HOOKS! " - "Install must add hooks to verify idempotency is meaningful." - ) - assert settings_after_first != settings_before, ( - "FIRST INSTALL DID NOT MODIFY SETTINGS! " - "Install must modify settings on first run to verify idempotency." - ) + assert_install_added_hooks(settings_before, settings_after_first) # Run multiple more installs - for i in range(5): - result = runner.invoke( - cli, - ["install", "--platform", "claude", "--path", str(mock_claude_project)], - catch_exceptions=False, - ) - assert result.exit_code == 0, f"Install #{i + 2} failed: {result.output}" + for _ in range(5): + run_install(mock_claude_project) # Final state should match first install - settings_after_many = json.loads(settings_file.read_text()) + settings_after_many = get_project_settings(mock_claude_project) assert settings_after_first == settings_after_many, ( "SETTINGS DIVERGED AFTER MULTIPLE INSTALLS! " From 59f90040ffe6de8e28703b4b717536c8082839de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 17:37:18 +0000 Subject: [PATCH 4/4] Add context manager for mock local Claude settings setup Extract redundant mock home directory setup into a reusable context manager that handles directory creation, settings file writing, and HOME environment patching in one place. --- .../integration/test_install_requirements.py | 95 ++++++++++--------- 1 file changed, 51 insertions(+), 44 deletions(-) diff --git a/tests/integration/test_install_requirements.py b/tests/integration/test_install_requirements.py index cb2ca6db..63d8dcba 100644 --- a/tests/integration/test_install_requirements.py +++ b/tests/integration/test_install_requirements.py @@ -21,6 +21,8 @@ """ import json +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from unittest.mock import patch @@ -74,6 +76,33 @@ def assert_install_added_hooks(settings_before: dict, settings_after: dict) -> N ) +@contextmanager +def mock_local_claude_settings( + tmp_path: Path, content: str | dict = '{"local": "unchanged"}' +) -> Iterator[Path]: + """Create mock local Claude settings and patch HOME to use them. + + Args: + tmp_path: Temporary directory to create mock home in + content: Settings content (string or dict to be JSON-serialized) + + Yields: + Path to the local settings file (for verification after install) + """ + mock_home = tmp_path / "mock_home" + mock_local_claude_dir = mock_home / ".claude" + mock_local_claude_dir.mkdir(parents=True) + + local_settings_file = mock_local_claude_dir / "settings.json" + if isinstance(content, dict): + local_settings_file.write_text(json.dumps(content, indent=2)) + else: + local_settings_file.write_text(content) + + with patch.dict("os.environ", {"HOME": str(mock_home)}): + yield local_settings_file + + # ============================================================================= # REQ-001: Install MUST NOT modify local (user home) Claude settings # ============================================================================= @@ -114,44 +143,30 @@ def test_install_does_not_modify_local_claude_settings( DO NOT MODIFY THIS TEST. """ - # Create a mock local Claude settings directory in tmp_path - mock_home = tmp_path / "mock_home" - mock_local_claude_dir = mock_home / ".claude" - mock_local_claude_dir.mkdir(parents=True) - - # Create local settings with known content - local_settings_file = mock_local_claude_dir / "settings.json" original_local_settings = { "user_preference": "do_not_change", "api_key_encrypted": "sensitive_data_here", "custom_config": {"setting1": True, "setting2": "value"}, } - local_settings_file.write_text(json.dumps(original_local_settings, indent=2)) - # Record the original file modification time - original_mtime = local_settings_file.stat().st_mtime - - # Run install with mocked home directory - with patch.dict("os.environ", {"HOME": str(mock_home)}): + with mock_local_claude_settings(tmp_path, original_local_settings) as local_file: + original_mtime = local_file.stat().st_mtime run_install(mock_claude_project) - # CRITICAL: Verify local settings were NOT modified - assert local_settings_file.exists(), "Local settings file should still exist" + # CRITICAL: Verify local settings were NOT modified + assert local_file.exists(), "Local settings file should still exist" - # Check content is unchanged - current_local_settings = json.loads(local_settings_file.read_text()) - assert current_local_settings == original_local_settings, ( - "LOCAL SETTINGS WERE MODIFIED! " - "Install MUST NOT touch user's home directory Claude settings. " - f"Expected: {original_local_settings}, Got: {current_local_settings}" - ) + current_local_settings = json.loads(local_file.read_text()) + assert current_local_settings == original_local_settings, ( + "LOCAL SETTINGS WERE MODIFIED! " + "Install MUST NOT touch user's home directory Claude settings. " + f"Expected: {original_local_settings}, Got: {current_local_settings}" + ) - # Check modification time is unchanged - current_mtime = local_settings_file.stat().st_mtime - assert current_mtime == original_mtime, ( - "LOCAL SETTINGS FILE WAS TOUCHED! " - "Install MUST NOT access user's home directory Claude settings." - ) + assert local_file.stat().st_mtime == original_mtime, ( + "LOCAL SETTINGS FILE WAS TOUCHED! " + "Install MUST NOT access user's home directory Claude settings." + ) def test_install_only_modifies_project_settings( self, mock_claude_project: Path, tmp_path: Path @@ -164,27 +179,19 @@ def test_install_only_modifies_project_settings( DO NOT MODIFY THIS TEST. """ - # Create mock local Claude settings - mock_home = tmp_path / "mock_home" - mock_local_claude_dir = mock_home / ".claude" - mock_local_claude_dir.mkdir(parents=True) - - local_settings_file = mock_local_claude_dir / "settings.json" original_local_content = '{"local": "unchanged"}' - local_settings_file.write_text(original_local_content) - # Run install - with patch.dict("os.environ", {"HOME": str(mock_home)}): + with mock_local_claude_settings(tmp_path, original_local_content) as local_file: run_install(mock_claude_project) - # Verify LOCAL settings unchanged - assert local_settings_file.read_text() == original_local_content, ( - "Local settings were modified! Install must only modify project settings." - ) + # Verify LOCAL settings unchanged + assert local_file.read_text() == original_local_content, ( + "Local settings were modified! Install must only modify project settings." + ) - # Verify PROJECT settings were modified (hooks should be added) - project_settings = get_project_settings(mock_claude_project) - assert "hooks" in project_settings, "Project settings should have hooks after install" + # Verify PROJECT settings were modified (hooks should be added) + project_settings = get_project_settings(mock_claude_project) + assert "hooks" in project_settings, "Project settings should have hooks after install" # =============================================================================