"""Acceptance tests for Packet S6: CmdForge GUI Provider Install Dialog reconciliation.""" import json import os import sys import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest os.environ["QT_QPA_PLATFORM"] = "offscreen" from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QMessageBox # Ensure single application instance for tests _qapp = QApplication.instance() if _qapp is None: _qapp = QApplication(["cmdforge-gui-tests", "-platform", "offscreen"]) from cmdforge.gui.dialogs.provider_install_dialog import ( PROVIDER_INSTALL_INFO, SUPPORTED_PROVIDERS, ProviderInstallDialog, SetupTerminalWorker, find_terminal_emulator, ) from cmdforge.providers import Provider, get_provider, load_providers, save_providers @pytest.fixture def temp_providers_file(tmp_path): providers_file = tmp_path / ".cmdforge" / "providers.yaml" with patch("cmdforge.providers.PROVIDERS_FILE", providers_file): save_providers([]) yield providers_file def test_supported_providers_catalogue_reconciliation(): """Verify that duplicate raw shell install commands and requirements are retired.""" assert "claude" in SUPPORTED_PROVIDERS assert "codex" in SUPPORTED_PROVIDERS assert "agy" in SUPPORTED_PROVIDERS assert "opencode" in SUPPORTED_PROVIDERS assert "crush" in SUPPORTED_PROVIDERS assert "ollama" in SUPPORTED_PROVIDERS # Ensure no entry has raw install_cmd shell scripts or requires strings for name, info in SUPPORTED_PROVIDERS.items(): assert "install_cmd" not in info, f"Provider {name} still contains raw install_cmd" assert "requires" not in info, f"Provider {name} still contains requires" assert "group" in info assert "setup" in info assert "cost" in info assert "variants" in info assert len(info["variants"]) > 0 # Ensure backwards compatibility alias assert PROVIDER_INSTALL_INFO is SUPPORTED_PROVIDERS def test_find_terminal_emulator(): """Verify terminal emulator detection logic.""" with patch("shutil.which", side_effect=lambda cmd: "/usr/bin/gnome-terminal" if cmd == "gnome-terminal" else None): launcher = find_terminal_emulator() assert launcher == ["/usr/bin/gnome-terminal", "--"] with patch("shutil.which", side_effect=lambda cmd: "/usr/bin/x-terminal-emulator" if cmd == "x-terminal-emulator" else None): launcher = find_terminal_emulator() assert launcher == ["/usr/bin/x-terminal-emulator", "-e"] with patch("shutil.which", return_value=None): launcher = find_terminal_emulator() assert launcher is None def test_dialog_init_and_provider_selection(temp_providers_file): """Verify dialog initialization and detail rendering for automated and manual providers.""" with patch("shutil.which", return_value=None): dialog = ProviderInstallDialog() assert dialog.provider_list.count() == len(SUPPORTED_PROVIDERS) assert not dialog.btn_install.isEnabled() # Select Claude (automated setup helper) claude_item = None for i in range(dialog.provider_list.count()): item = dialog.provider_list.item(i) if item.data(Qt.UserRole) == "claude": claude_item = item break assert claude_item is not None dialog._on_provider_selected(claude_item) assert dialog._selected_provider == "claude" assert dialog.btn_install.isEnabled() assert dialog.btn_install.text() == "Install Selected" assert "Agent Harness Setup Helper (terminal)" in dialog.details_label.text() assert "Anthropic Claude" in dialog.details_label.text() # Select AGY (manual setup instructions) agy_item = None for i in range(dialog.provider_list.count()): item = dialog.provider_list.item(i) if item.data(Qt.UserRole) == "agy": agy_item = item break assert agy_item is not None dialog._on_provider_selected(agy_item) assert dialog._selected_provider == "agy" assert dialog.btn_install.isEnabled() assert dialog.btn_install.text() == "Setup Instructions" assert "Manual desktop setup" in dialog.details_label.text() dialog.close() def test_start_install_missing_helper_shows_warning(temp_providers_file): """Verify missing setup helper displays warning and does not advance page.""" with patch("shutil.which", return_value=None): dialog = ProviderInstallDialog() dialog._selected_provider = "claude" with ( patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=None), patch.object(QMessageBox, "warning") as mock_warn, ): dialog._start_install() assert mock_warn.called assert "Agent Harness Setup Helper ('agent-harness-setup') was not found" in mock_warn.call_args[0][2] # Verify dialog stayed on Page 0 (Selection) assert dialog.stack.currentIndex() == 0 dialog.close() def test_setup_terminal_worker_success(tmp_path): """Test SetupTerminalWorker success path with valid result correlation.""" result_file = tmp_path / "result.json" req_id = "test-uuid-1234" data = { "status": "success", "request_id": req_id, "harness_id": "claude-code", "path_updates": ["/custom/bin"], } class MockProcess: def __init__(self, *args, **kwargs): self.stdout = None result_file.write_text(json.dumps(data), encoding="utf-8") def wait(self): return 0 def poll(self): return 0 worker = SetupTerminalWorker( helper_argv=["/bin/agent-harness-setup"], harness_id="claude-code", result_file=result_file, request_id=req_id, terminal_launcher=["/usr/bin/xterm", "-e"], ) results = [] worker.finished.connect(lambda s, m, d: results.append((s, m, d))) with patch("subprocess.Popen", side_effect=MockProcess): worker.run() assert len(results) == 1 success, msg, res_dict = results[0] assert success is True assert "verified successfully" in msg assert res_dict["path_updates"] == ["/custom/bin"] def test_setup_terminal_worker_security_mismatch(tmp_path): """Test SetupTerminalWorker rejects result with request_id mismatch.""" result_file = tmp_path / "result.json" data = { "status": "success", "request_id": "wrong-uuid", "harness_id": "claude-code", } class MockProcess: def __init__(self, *args, **kwargs): self.stdout = None result_file.write_text(json.dumps(data), encoding="utf-8") def wait(self): return 0 worker = SetupTerminalWorker( helper_argv=["/bin/agent-harness-setup"], harness_id="claude-code", result_file=result_file, request_id="expected-uuid", ) results = [] worker.finished.connect(lambda s, m, d: results.append((s, m, d))) with patch("subprocess.Popen", side_effect=MockProcess): worker.run() assert len(results) == 1 success, msg, _ = results[0] assert success is False assert "Security error" in msg def test_setup_terminal_worker_incoherent_exit(tmp_path): """Test SetupTerminalWorker rejects exit code 1 with status 'success'.""" result_file = tmp_path / "result.json" req_id = "test-uuid" data = { "status": "success", "request_id": req_id, } class MockProcess: def __init__(self, *args, **kwargs): self.stdout = None result_file.write_text(json.dumps(data), encoding="utf-8") def wait(self): return 1 worker = SetupTerminalWorker( helper_argv=["/bin/agent-harness-setup"], harness_id="claude-code", result_file=result_file, request_id=req_id, ) results = [] worker.finished.connect(lambda s, m, d: results.append((s, m, d))) with patch("subprocess.Popen", side_effect=MockProcess): worker.run() assert len(results) == 1 success, msg, _ = results[0] assert success is False assert "error code 1" in msg def test_setup_terminal_worker_cancelled(tmp_path): """Test SetupTerminalWorker handles user cancellation cleanly.""" result_file = tmp_path / "result.json" req_id = "cancel-uuid" data = { "status": "cancelled", "request_id": req_id, } class MockProcess: def __init__(self, *args, **kwargs): self.stdout = None result_file.write_text(json.dumps(data), encoding="utf-8") def wait(self): return 10 worker = SetupTerminalWorker( helper_argv=["/bin/agent-harness-setup"], harness_id="claude-code", result_file=result_file, request_id=req_id, ) results = [] worker.finished.connect(lambda s, m, d: results.append((s, m, d))) with patch("subprocess.Popen", side_effect=MockProcess): worker.run() assert len(results) == 1 success, msg, _ = results[0] assert success is False assert "cancelled" in msg.lower() def test_setup_terminal_worker_failure_with_diagnostics(tmp_path): """Test SetupTerminalWorker handles failure with error diagnostics.""" result_file = tmp_path / "result.json" req_id = "fail-uuid" data = { "status": "failed", "request_id": req_id, "error_diagnostics": "Missing npm dependency", } class MockProcess: def __init__(self, *args, **kwargs): self.stdout = None result_file.write_text(json.dumps(data), encoding="utf-8") def wait(self): return 1 worker = SetupTerminalWorker( helper_argv=["/bin/agent-harness-setup"], harness_id="claude-code", result_file=result_file, request_id=req_id, ) results = [] worker.finished.connect(lambda s, m, d: results.append((s, m, d))) with patch("subprocess.Popen", side_effect=MockProcess): worker.run() assert len(results) == 1 success, msg, _ = results[0] assert success is False assert "Missing npm dependency" in msg def test_dialog_full_flow_success_to_variants_and_add(temp_providers_file, tmp_path): """Test full dialog flow: install succeeds, advances to variants, registers selected variants.""" with patch("shutil.which", return_value=None): dialog = ProviderInstallDialog() dialog._selected_provider = "claude" mock_worker = MagicMock() with ( patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=["/bin/agent-harness-setup"]), patch("cmdforge.gui.dialogs.provider_install_dialog.SetupTerminalWorker", return_value=mock_worker), ): dialog._start_install() assert dialog.stack.currentIndex() == 1 # Install page assert "Installing Anthropic Claude" in dialog.install_title.text() # Create dummy directory for path update test custom_bin = tmp_path / "bin" custom_bin.mkdir(parents=True, exist_ok=True) result_data = { "status": "success", "request_id": "any", "harness_id": "claude-code", "path_updates": [str(custom_bin)], } dialog._on_setup_finished(True, "Setup verified!", result_data) assert str(custom_bin) in os.environ.get("PATH", "") assert dialog.stack.currentIndex() == 2 assert dialog.variants_list.count() == len(SUPPORTED_PROVIDERS["claude"]["variants"]) selected_items = [dialog.variants_list.item(i) for i in range(dialog.variants_list.count()) if dialog.variants_list.item(i).isSelected()] assert len(selected_items) > 0 with patch.object(QMessageBox, "information") as mock_info: dialog._add_selected_variants() assert mock_info.called provider = get_provider("claude") assert provider is not None assert provider.command == "claude -p" dialog.close() def test_dialog_failure_flow_shows_warning(temp_providers_file): """Test dialog failure flow warns user and remains on install page.""" with patch("shutil.which", return_value=None): dialog = ProviderInstallDialog() dialog._selected_provider = "claude" mock_worker = MagicMock() with ( patch("cmdforge.gui.dialogs.provider_install_dialog._find_setup_helper", return_value=["/bin/agent-harness-setup"]), patch("cmdforge.gui.dialogs.provider_install_dialog.SetupTerminalWorker", return_value=mock_worker), ): dialog._start_install() with patch.object(QMessageBox, "warning") as mock_warn: dialog._on_setup_finished(False, "Compilation error in installer", {}) assert mock_warn.called assert "Compilation error in installer" in mock_warn.call_args[0][2] assert dialog.stack.currentIndex() == 1 assert "Installation Incomplete" in dialog.install_title.text() assert dialog.btn_back.isEnabled() dialog.close() def test_dialog_close_cleans_up_resources(temp_providers_file): """Test closing dialog terminates active worker and removes temporary directory.""" with patch("shutil.which", return_value=None): dialog = ProviderInstallDialog() temp_dir = tempfile.mkdtemp(prefix="test-cleanup-") dialog._temp_dir = temp_dir mock_worker = MagicMock() mock_worker.isRunning.return_value = True dialog._worker = mock_worker dialog.close() assert mock_worker.terminate_process.called assert not Path(temp_dir).exists()