arcade-mcp/tests/install/test_install.py
jottakka fe8ddfd500
[TOO-326] Windows papercuts (#768)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Medium Risk**
> Touches authentication/login flow, credentials-file permissions, and
subprocess lifecycle behavior across platforms; while mostly defensive,
regressions could impact login or process management on Windows/macOS
runners.
> 
> **Overview**
> Improves Windows/cross-platform reliability across the CLI and MCP
server: OAuth login now binds the callback server to `127.0.0.1`, avoids
slow loopback reverse-DNS, adds a configurable callback timeout
(`--timeout` + env default), and opens URLs via a Windows-friendly
`_open_browser` to avoid flashing console windows.
> 
> Centralizes CLI output via a shared `console` that forces UTF-8 on
Windows, standardizes UTF-8 file reads/writes throughout, tightens
credentials-file permissions on Windows using `icacls`, and adds shared
Windows subprocess helpers for **no-window** process creation and
graceful termination (used by `deploy`, MCP reload, and usage-tracking
worker).
> 
> Updates client configuration UX/robustness (Windows AppData resolution
via `platformdirs`, Cursor config path fallbacks + compatibility writes,
overwrite warnings, absolute `uv` path for GUI clients, safer path
display) and improves `deploy` child-process handling to avoid
pipe-buffer deadlocks while giving better debug-aware error messages.
> 
> Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI
integration workflow, disables usage tracking in toolkits CI, and adds
extensive regression tests for Windows signals, subprocess cleanup,
UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and
`arcade-mcp-server` to `1.17.2` (with updated dependency pin).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-25 13:18:16 -03:00

328 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Test script to verify arcade-mcp installation from source.
This script:
1. Installs arcade-mcp from source using uv
2. Runs basic CLI commands to verify functionality
3. Tests cross-platform compatibility (file locking with portalocker)
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
# Ensure UTF-8 encoding for cross-platform compatibility (especially Windows)
if sys.platform == "win32":
# Set UTF-8 encoding for Windows console
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
# Set environment variable for subprocesses
os.environ["PYTHONIOENCODING"] = "utf-8"
class TestRunner:
"""Organizes and runs installation tests for arcade-mcp."""
def __init__(self, project_root: Path):
"""Initialize test runner with project root."""
self.project_root = project_root
self.arcade_cmd = self._find_arcade_command()
self.test_results: list[tuple[str, bool]] = []
def _find_arcade_command(self) -> list[str]:
"""Find the arcade command (either direct or via uv run).
When using uv run from temp dirs (e.g. configure tests), use
``--project`` instead of ``--directory`` so uv resolves the project
environment without changing the subprocess working directory.
"""
if shutil.which("arcade"):
return ["arcade"]
return ["uv", "run", "--project", str(self.project_root), "arcade"]
def run_command(
self,
cmd: list[str],
description: str,
required: bool = True,
cwd: Path | None = None,
input_text: str | None = None,
) -> tuple[bool, str]:
"""Run a command and return success status and output."""
print(f"\n{'=' * 60}")
print(f"Testing: {description}")
print(f"Command: {' '.join(cmd)}")
print(f"{'=' * 60}")
# Ensure UTF-8 encoding for cross-platform compatibility (especially Windows)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
timeout=60,
env=env,
cwd=str(cwd) if cwd else None,
input=input_text,
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired:
print(f"❌ Timeout: {description}")
self.test_results.append((description, False))
return False, "Command timed out"
except subprocess.CalledProcessError as e:
print(f"❌ Failed: {description}")
print(f"Return code: {e.returncode}")
if e.stdout:
print(f"Stdout:\n{e.stdout}")
if e.stderr:
print(f"Stderr:\n{e.stderr}")
self.test_results.append((description, False))
if required:
return False, e.stderr or e.stdout or ""
return False, e.stderr or e.stdout or ""
except Exception as e:
print(f"❌ Error: {description}")
print(f"Exception: {e}")
self.test_results.append((description, False))
if required:
return False, str(e)
return False, str(e)
else:
print(f"✅ Success: {description}")
if result.stdout:
print(f"Output:\n{result.stdout}")
self.test_results.append((description, True))
output = result.stdout
return True, output
def check_prerequisites(self) -> bool:
"""Check that required tools are available."""
print("\n" + "=" * 60)
print("Prerequisites Check")
print("=" * 60)
success, _ = self.run_command(["uv", "--version"], "Check uv availability", required=True)
return success
def _sync_dependencies_command(
self,
*,
current_test: str | None = None,
) -> list[str]:
"""Build the `uv sync` command for the current runtime context.
When this suite runs under pytest, avoid reinstalling pytest itself.
This prevents mutating the active test runner environment and also
avoids `pytest.exe` file-lock failures on Windows.
"""
runtime_test = (
current_test if current_test is not None else os.environ.get("PYTEST_CURRENT_TEST")
)
command = ["uv", "sync", "--dev"]
if runtime_test:
command.extend(["--inexact", "--no-install-package", "pytest"])
return command
def install_package(self) -> bool:
"""Install arcade-mcp from source."""
print("\n" + "=" * 60)
print("Installation Phase")
print("=" * 60)
# Sync dependencies
sync_success, _ = self.run_command(
self._sync_dependencies_command(),
"Sync dependencies with uv",
required=True,
)
if not sync_success:
return False
# Install package in editable mode
install_success, _ = self.run_command(
["uv", "pip", "install", "-e", str(self.project_root)],
"Install arcade-mcp from source (editable mode)",
required=True,
)
return install_success
def test_cli_availability(self) -> bool:
"""Test that the CLI is available and working."""
print("\n" + "=" * 60)
print("CLI Functionality Tests")
print("=" * 60)
# Test --help
help_success, _ = self.run_command(
[*self.arcade_cmd, "--help"],
"Verify arcade CLI is available (--help)",
required=True,
)
if not help_success:
return False
# Test --version (optional, might not exist)
self.run_command(
[*self.arcade_cmd, "--version"],
"Check arcade version",
required=False,
)
# Test whoami (might fail if not logged in, but shouldn't crash)
self.run_command(
[*self.arcade_cmd, "whoami"],
"Test whoami command (no auth required)",
required=False,
)
return True
def test_file_locking(self) -> bool:
"""Test cross-platform file locking with portalocker."""
print("\n" + "=" * 60)
print("File Locking Tests (portalocker)")
print("=" * 60)
test_code = """
import tempfile
import json
from pathlib import Path
from arcade_core.usage.identity import UsageIdentity
import os
# Create a temporary directory for testing
with tempfile.TemporaryDirectory() as tmpdir:
# Monkey patch the config path
import arcade_core.usage.identity as identity_module
original_path = identity_module.ARCADE_CONFIG_PATH
identity_module.ARCADE_CONFIG_PATH = tmpdir
try:
# Create identity instance and test file operations
identity = UsageIdentity()
# Test load_or_create (uses file locking)
data1 = identity.load_or_create()
assert "anon_id" in data1
print(f"✅ Successfully created identity with anon_id: {data1['anon_id']}")
# Test that we can read it back (uses shared lock)
identity2 = UsageIdentity()
data2 = identity2.load_or_create()
assert data2["anon_id"] == data1["anon_id"]
print(f"✅ Successfully read identity with file locking")
# Test atomic write (uses exclusive lock)
identity.set_linked_principal_id("test-principal-123")
data3 = identity.load_or_create()
assert data3["linked_principal_id"] == "test-principal-123"
print(f"✅ Successfully wrote identity with file locking")
print("✅ All portalocker file locking tests passed!")
finally:
identity_module.ARCADE_CONFIG_PATH = original_path
"""
# Use uv run to ensure we're in the right environment
python_cmd = ["uv", "run", "python", "-c", test_code]
success, _ = self.run_command(
python_cmd,
"Test portalocker file locking (cross-platform)",
required=True,
)
return success
def run_all_tests(self) -> int:
"""Run all tests and return exit code."""
print("=" * 60)
print("Testing arcade-mcp Installation from Source")
print("=" * 60)
print(f"\nProject root: {self.project_root}")
# Run test phases
test_phases = [
("Prerequisites", self.check_prerequisites),
("Installation", self.install_package),
("CLI Functionality", self.test_cli_availability),
("File Locking", self.test_file_locking),
]
for phase_name, test_func in test_phases:
if not test_func():
print(f"\n{phase_name} phase failed. Stopping tests.")
return 1
# Print summary
self.print_summary()
return 0
def print_summary(self) -> None:
"""Print test summary."""
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
passed = sum(1 for _, success in self.test_results if success)
total = len(self.test_results)
for description, success in self.test_results:
status = "✅ PASSED" if success else "❌ FAILED"
print(f"{status}: {description}")
print(f"\nTotal: {passed}/{total} tests passed")
if passed == total:
print("\n🎉 All tests passed! arcade-mcp is working correctly.")
else:
print(f"\n⚠️ {total - passed} test(s) failed.")
def main() -> int:
"""Main entry point."""
# Get project root (two levels up from tests/install/)
project_root = Path(__file__).parent.parent.parent.absolute()
runner = TestRunner(project_root)
return runner.run_all_tests()
def test_installation() -> None:
"""Pytest entry point for the installation test suite.
Delegates to the existing TestRunner so the full install validation
runs under pytest (picks up conftest.py fixtures such as
disable_usage_tracking) without changing the internal test logic.
"""
exit_code = main()
assert exit_code == 0, "One or more installation tests failed — see output above."
def test_sync_dependencies_command_default(tmp_path: Path) -> None:
"""Use the baseline sync command outside Windows+pytest context."""
runner = TestRunner(tmp_path)
assert runner._sync_dependencies_command(current_test="") == ["uv", "sync", "--dev"]
def test_sync_dependencies_command_pytest_context(tmp_path: Path) -> None:
"""Avoid reinstalling pytest when this suite itself is running."""
runner = TestRunner(tmp_path)
assert runner._sync_dependencies_command(
current_test="tests/install/test_install.py::test_installation (call)",
) == ["uv", "sync", "--dev", "--inexact", "--no-install-package", "pytest"]
if __name__ == "__main__":
sys.exit(main())