<!-- 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 -->
328 lines
11 KiB
Python
Executable file
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())
|