"""Integration test for external editor GUI functionality - opt-in verification. These tests verify that: 1. WITHOUT external editor configured - "Open in External Editor" button doesn't appear 2. WITH external editor configured - "Open in External Editor" button DOES appear and launches VSCode Manual verification: 1. Run: uv run sloppy.py 2. Trigger a patch from Tier 4 QA or agent 3. See if "Open in External Editor" button appears based on config """ import pytest import time import sys import os import tempfile from unittest.mock import patch, MagicMock sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) from src import api_hook_client @pytest.fixture def config_without_external_editor(): return { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "paths": {"logs_dir": "logs", "scripts_dir": "scripts"}, "tools": {} } @pytest.fixture def config_with_vscode_external_editor(): return { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "paths": {"logs_dir": "logs", "scripts_dir": "scripts"}, "tools": { "text_editors": { "vscode": { "path": "C:\\apps\\Microsoft VS Code\\Code.exe", "diff_args": ["--new-window", "--diff"] } }, "default_editor": {"default_editor": "vscode"} } } @pytest.mark.integration @pytest.mark.timeout(120) def test_without_external_editor_button_not_shown(live_gui, monkeypatch): """Test that WITHOUT external editor configured, the button doesn't appear.""" proc, _ = live_gui client = api_hook_client.ApiHookClient() if not client.wait_for_server(timeout=15): pytest.skip("GUI server not available") import src.models as models_module monkeypatch.setattr(models_module, 'load_config', lambda: config_without_external_editor()) sample_patch = """--- a/test.py +++ b/test.py @@ -1,2 +1,3 @@ x = 1 -y = 2 +y = 3 +z = 4""" client.push_event("show_patch_modal", { "patch_text": sample_patch, "file_paths": ["test.py"] }) time.sleep(2) state = client.get_gui_state() assert state.get("_show_patch_modal") == True print("\n=== OPT-OUT TEST ===") print("External editor NOT configured in tools.text_editors") print("Button 'Open in External Editor' should NOT be visible in patch modal") print("===================") client.push_event("hide_patch_modal", {}) time.sleep(1) @pytest.mark.integration @pytest.mark.timeout(120) def test_with_external_editor_button_shown_and_launches(live_gui, monkeypatch): """Test that WITH external editor configured, the button appears and launches VSCode.""" proc, _ = live_gui client = api_hook_client.ApiHookClient() if not client.wait_for_server(timeout=15): pytest.skip("GUI server not available") import src.models as models_module monkeypatch.setattr(models_module, 'load_config', lambda: config_with_vscode_external_editor()) sample_patch = """--- a/test.py +++ b/test.py @@ -1,2 +1,3 @@ x = 1 -y = 2 +y = 3 +z = 4""" client.push_event("show_patch_modal", { "patch_text": sample_patch, "file_paths": ["test.py"] }) time.sleep(2) state = client.get_gui_state() assert state.get("_show_patch_modal") == True print("\n=== OPT-IN TEST ===") print("External editor CONFIGURED:") print(" path: C:\\apps\\Microsoft VS Code\\Code.exe") print(" diff_args: ['--new-window', '--diff']") print("Button 'Open in External Editor' SHOULD be visible in patch modal") print("Clicking it will launch VSCode with --diff view") print("===================") with patch('subprocess.Popen') as mock_popen: mock_popen.return_value = MagicMock() client.push_event("click_button", {"button": "Open in External Editor"}) time.sleep(1) if mock_popen.called: call_args = mock_popen.call_args[0][0] print(f"VSCode launched with command: {call_args}") assert "--diff" in call_args or "--new-window" in call_args else: print("subprocess.Popen was NOT called (button click may not be wired up via API)") print("Manual test: Click 'Open in External Editor' button in the GUI") client.push_event("hide_patch_modal", {}) time.sleep(1) @pytest.mark.integration @pytest.mark.timeout(120) def test_verify_vscode_command_format_directly(): """Direct test of VSCode diff command - no GUI needed.""" from src.external_editor import ExternalEditorLauncher, ExternalEditorConfig, TextEditorConfig config = ExternalEditorConfig( editors={ "vscode": TextEditorConfig( name="vscode", path="C:\\apps\\Microsoft VS Code\\Code.exe", diff_args=["--new-window", "--diff"] ) }, default_editor="vscode" ) launcher = ExternalEditorLauncher(config) with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: f.write("original line\n") orig = f.name with tempfile.NamedTemporaryFile(mode="w", suffix="_modified.txt", delete=False, encoding="utf-8") as f: f.write("modified line\n") mod = f.name try: cmd = launcher.build_diff_command( launcher.config.editors["vscode"], orig, mod ) print(f"\n=== VSCODE DIFF COMMAND ===") print(f"Full command: {cmd}") print(f"Expected: Code.exe --new-window --diff ") assert "--diff" in cmd assert "--new-window" in cmd assert "Code.exe" in cmd[0] print("Command format: CORRECT") print("=========================") finally: os.unlink(orig) os.unlink(mod) if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) """Integration test for external editor GUI functionality. These tests verify that the external editor configuration is properly loaded and the patch modal shows the option to open in external editor. Manual verification: 1. Run: uv run sloppy.py 2. Ensure config.toml has external editor configured 3. Have an agent/Tier 4 generate a patch 4. Click "Open in External Editor" in the patch modal 5. VSCode should open with diff view showing original vs modified """ import pytest import time import sys import os import tempfile sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) from src import api_hook_client @pytest.fixture def test_external_editor_config(): return { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "paths": {"logs_dir": "logs", "scripts_dir": "scripts"}, "tools": { "text_editors": { "vscode": { "path": "C:\\apps\\Microsoft VS Code\\Code.exe", "diff_args": ["--new-window", "--diff"] } }, "default_editor": {"default_editor": "vscode"} } } @pytest.mark.integration @pytest.mark.timeout(120) def test_external_editor_config_shows_in_panel(live_gui, monkeypatch): proc, _ = live_gui client = api_hook_client.ApiHookClient() if not client.wait_for_server(timeout=15): pytest.skip("GUI server not available") test_config = { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "paths": {"logs_dir": "logs", "scripts_dir": "scripts"}, "tools": { "text_editors": { "vscode": { "path": "C:\\apps\\Microsoft VS Code\\Code.exe", "diff_args": ["--new-window", "--diff"] } }, "default_editor": {"default_editor": "vscode"} } } import src.models as models_module monkeypatch.setattr(models_module, 'load_config', lambda: test_config) time.sleep(1) state = client.get_gui_state() print(f"GUI state keys: {list(state.keys())[:10]}...") @pytest.mark.integration @pytest.mark.timeout(120) def test_patch_modal_appears_with_external_editor(live_gui, monkeypatch): proc, _ = live_gui client = api_hook_client.ApiHookClient() if not client.wait_for_server(timeout=15): pytest.skip("GUI server not available") test_config = { "ai": {"provider": "gemini", "model": "gemini-2.5-flash-lite"}, "projects": {"paths": [], "active": ""}, "paths": {"logs_dir": "logs", "scripts_dir": "scripts"}, "tools": { "text_editors": { "vscode": { "path": "C:\\apps\\Microsoft VS Code\\Code.exe", "diff_args": ["--new-window", "--diff"] } }, "default_editor": {"default_editor": "vscode"} } } import src.models as models_module monkeypatch.setattr(models_module, 'load_config', lambda: test_config) sample_patch = """--- a/test_file.py +++ b/test_file.py @@ -1,3 +1,4 @@ def hello(): - print("old") + print("new") + print("extra") return True""" client.push_event("show_patch_modal", { "patch_text": sample_patch, "file_paths": ["test_file.py"] }) time.sleep(2) state = client.get_gui_state() assert state.get("_show_patch_modal") == True, f"Patch modal should be visible: {state}" assert state.get("_pending_patch_text") is not None, "Pending patch text should be set" print("Patch modal visible with external editor configured") print("To manually test: Click 'Open in External Editor' button to launch VSCode") client.push_event("hide_patch_modal", {}) time.sleep(1) @pytest.mark.integration @pytest.mark.timeout(120) def test_verify_vscode_diff_command_format(live_gui): from src.external_editor import ExternalEditorLauncher, ExternalEditorConfig, TextEditorConfig config = ExternalEditorConfig( editors={ "vscode": TextEditorConfig( name="vscode", path="C:\\apps\\Microsoft VS Code\\Code.exe", diff_args=["--new-window", "--diff"] ) }, default_editor="vscode" ) launcher = ExternalEditorLauncher(config) with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f: f.write("original\n") orig = f.name with tempfile.NamedTemporaryFile(mode="w", suffix="_modified.txt", delete=False, encoding="utf-8") as f: f.write("modified\n") mod = f.name try: cmd = launcher.build_diff_command( launcher.config.editors["vscode"], orig, mod ) assert "--diff" in cmd, f"VSCode command should include --diff: {cmd}" assert "Code.exe" in cmd[0], f"Should launch Code.exe: {cmd}" print(f"VSCode diff command correctly formatted: {cmd}") finally: os.unlink(orig) os.unlink(mod) if __name__ == "__main__": pytest.main([__file__, "-v", "-s"])