test: Add opt-in/opt-out tests for external editor GUI

This commit is contained in:
2026-05-07 20:29:35 -04:00
parent 153b790f31
commit fbd9e07f68
3 changed files with 234 additions and 142 deletions
+38 -28
View File
@@ -74,10 +74,10 @@ Collapsed=0
DockId=0xAFC85805,2 DockId=0xAFC85805,2
[Window][Theme] [Window][Theme]
Pos=0,975 Pos=0,28
Size=1010,730 Size=1874,1889
Collapsed=0 Collapsed=0
DockId=0x00000007,0 DockId=0x00000005,3
[Window][Text Viewer - Entry #7] [Window][Text Viewer - Entry #7]
Pos=379,324 Pos=379,324
@@ -102,26 +102,26 @@ Collapsed=0
DockId=0x0000000D,0 DockId=0x0000000D,0
[Window][Discussion Hub] [Window][Discussion Hub]
Pos=87,24 Pos=1876,28
Size=1593,1176 Size=1209,1889
Collapsed=0 Collapsed=0
DockId=0x00000006,0 DockId=0x00000006,0
[Window][Operations Hub] [Window][Operations Hub]
Pos=0,24 Pos=0,28
Size=85,1176 Size=1874,1889
Collapsed=0 Collapsed=0
DockId=0x00000005,2 DockId=0x00000005,2
[Window][Files & Media] [Window][Files & Media]
Pos=87,24 Pos=1876,28
Size=1593,1176 Size=1209,1889
Collapsed=0 Collapsed=0
DockId=0x00000006,1 DockId=0x00000006,1
[Window][AI Settings] [Window][AI Settings]
Pos=0,24 Pos=0,28
Size=85,1176 Size=1874,1889
Collapsed=0 Collapsed=0
DockId=0x00000005,0 DockId=0x00000005,0
@@ -131,14 +131,14 @@ Size=416,325
Collapsed=0 Collapsed=0
[Window][MMA Dashboard] [Window][MMA Dashboard]
Pos=87,24 Pos=1876,28
Size=1593,1176 Size=1209,1889
Collapsed=0 Collapsed=0
DockId=0x00000006,2 DockId=0x00000006,2
[Window][Log Management] [Window][Log Management]
Pos=87,24 Pos=1876,28
Size=1593,1176 Size=1209,1889
Collapsed=0 Collapsed=0
DockId=0x00000006,3 DockId=0x00000006,3
@@ -406,8 +406,8 @@ Collapsed=0
DockId=0x00000006,1 DockId=0x00000006,1
[Window][Project Settings] [Window][Project Settings]
Pos=0,24 Pos=0,28
Size=85,1176 Size=1874,1889
Collapsed=0 Collapsed=0
DockId=0x00000005,1 DockId=0x00000005,1
@@ -427,6 +427,16 @@ Pos=60,60
Size=900,700 Size=900,700
Collapsed=0 Collapsed=0
[Window][Text Viewer - Entry #16]
Pos=60,60
Size=1598,1531
Collapsed=0
[Window][Text Viewer - Entry #13]
Pos=1568,1057
Size=900,700
Collapsed=0
[Table][0xFB6E3870,4] [Table][0xFB6E3870,4]
RefScale=13 RefScale=13
Column 0 Width=80 Column 0 Width=80
@@ -458,11 +468,11 @@ Column 3 Width=20
Column 4 Weight=1.0000 Column 4 Weight=1.0000
[Table][0x2A6000B6,4] [Table][0x2A6000B6,4]
RefScale=16 RefScale=20
Column 0 Width=48 Column 0 Width=60
Column 1 Width=67 Column 1 Width=83
Column 2 Weight=1.0000 Column 2 Weight=1.0000
Column 3 Width=243 Column 3 Width=303
[Table][0x8BCC69C7,6] [Table][0x8BCC69C7,6]
RefScale=13 RefScale=13
@@ -481,11 +491,11 @@ Column 2 Weight=1.0000
Column 3 Width=135 Column 3 Width=135
[Table][0x2C515046,4] [Table][0x2C515046,4]
RefScale=16 RefScale=20
Column 0 Width=48 Column 0 Width=60
Column 1 Weight=1.0000 Column 1 Weight=1.0000
Column 2 Width=166 Column 2 Width=207
Column 3 Width=48 Column 3 Width=60
[Table][0xD99F45C5,4] [Table][0xD99F45C5,4]
Column 0 Sort=0v Column 0 Sort=0v
@@ -551,12 +561,12 @@ Column 2 Width=150
DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y
DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A
DockNode ID=0x0000000A Parent=0x00000008 SizeRef=1029,145 Selected=0xDF822E02 DockNode ID=0x0000000A Parent=0x00000008 SizeRef=1029,145 Selected=0xDF822E02
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,24 Size=1680,1176 Split=X DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,28 Size=3085,1889 Split=X
DockNode ID=0x00000003 Parent=0xAFC85805 SizeRef=2175,1183 Split=X DockNode ID=0x00000003 Parent=0xAFC85805 SizeRef=2175,1183 Split=X
DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=X Selected=0xF4139CA2 DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=X Selected=0xF4139CA2
DockNode ID=0x00000007 Parent=0x0000000B SizeRef=1512,858 Split=X Selected=0x8CA2375C DockNode ID=0x00000007 Parent=0x0000000B SizeRef=1512,858 Split=X Selected=0x8CA2375C
DockNode ID=0x00000005 Parent=0x00000007 SizeRef=1266,1681 CentralNode=1 Selected=0x7BD57D6A DockNode ID=0x00000005 Parent=0x00000007 SizeRef=840,1681 CentralNode=1 Selected=0x3F1379AF
DockNode ID=0x00000006 Parent=0x00000007 SizeRef=1593,1681 Selected=0x2C0206CE DockNode ID=0x00000006 Parent=0x00000007 SizeRef=1209,1681 Selected=0x2C0206CE
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1777,858 Selected=0x418C7449 DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1777,858 Selected=0x418C7449
DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6 DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6
DockNode ID=0x00000004 Parent=0xAFC85805 SizeRef=1162,1183 Split=X Selected=0x3AEC3498 DockNode ID=0x00000004 Parent=0xAFC85805 SizeRef=1162,1183 Split=X Selected=0x3AEC3498
+196
View File
@@ -1,3 +1,199 @@
"""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 <orig> <modified>")
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. """Integration test for external editor GUI functionality.
These tests verify that the external editor configuration is properly These tests verify that the external editor configuration is properly
-114
View File
@@ -1,114 +0,0 @@
"""Integration tests for external editor - launches real editors."""
import os
import tempfile
import pytest
from unittest.mock import patch, MagicMock
from src.external_editor import (
ExternalEditorLauncher,
ExternalEditorConfig,
TextEditorConfig,
create_temp_modified_file,
)
@pytest.fixture
def vscode_editor():
return TextEditorConfig(name="vscode", path="code.exe", diff_args=["--diff"])
@pytest.fixture
def ext_config(vscode_editor):
return ExternalEditorConfig(editors={"vscode": vscode_editor}, default_editor="vscode")
@pytest.fixture
def launcher(ext_config):
return ExternalEditorLauncher(ext_config)
class TestExternalEditorIntegration:
def test_create_temp_modified_file_creates_valid_file(self):
content = "line1\nline2\nline3\n"
path = create_temp_modified_file(content)
try:
assert os.path.exists(path)
with open(path, encoding="utf-8") as f:
assert f.read() == content
finally:
if os.path.exists(path):
os.unlink(path)
def test_build_diff_command_format(self, launcher, vscode_editor):
cmd = launcher.build_diff_command(vscode_editor, "original.txt", "modified.txt")
assert cmd == ["code.exe", "--diff", "original.txt", "modified.txt"]
@patch("subprocess.Popen")
def test_launch_diff_calls_subprocess(self, mock_popen, launcher):
mock_popen.return_value = MagicMock()
result = launcher.launch_diff("vscode", "orig.txt", "mod.txt")
assert result is not None
mock_popen.assert_called_once_with(["code.exe", "--diff", "orig.txt", "mod.txt"])
@patch("subprocess.Popen")
def test_launch_diff_with_real_files(self, mock_popen, launcher):
mock_popen.return_value = MagicMock()
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write("original content\n")
orig_path = f.name
try:
mod_path = create_temp_modified_file("modified content\n")
try:
result = launcher.launch_diff("vscode", orig_path, mod_path)
assert result is not None
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "code.exe"
assert call_args[1] == "--diff"
assert call_args[2] == orig_path
assert call_args[3] == mod_path
finally:
if os.path.exists(mod_path):
os.unlink(mod_path)
finally:
if os.path.exists(orig_path):
os.unlink(orig_path)
class TestExternalEditorWithRealVSCode:
@pytest.mark.skipif(
os.environ.get("TEST_REAL_VSCODE") != "1",
reason="Set TEST_REAL_VSCODE=1 environment variable to run this test"
)
def test_launch_real_vscode_diff(self):
vscode_path = os.environ.get("VSCODE_PATH", "code.exe")
if not os.path.exists(vscode_path) and vscode_path == "code.exe":
import shutil
if not shutil.which("code"):
pytest.skip("VSCode not found in PATH")
config = ExternalEditorConfig(
editors={"vscode": TextEditorConfig(name="vscode", path=vscode_path, diff_args=["--diff"])},
default_editor="vscode"
)
launcher = ExternalEditorLauncher(config)
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write("line1\nline2\nline3\n")
orig_path = f.name
try:
mod_path = create_temp_modified_file("line1\nmodified line2\nline3\n")
try:
import subprocess
proc = launcher.launch_diff("vscode", orig_path, mod_path)
assert proc is not None
import time
time.sleep(1)
proc.terminate()
proc.wait(timeout=5)
finally:
if os.path.exists(mod_path):
os.unlink(mod_path)
finally:
if os.path.exists(orig_path):
os.unlink(orig_path)
if __name__ == "__main__":
pytest.main([__file__, "-v"])