from __future__ import annotations
import os
import subprocess
import time
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
pass
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line(
"markers", "background: tests that verify background operation (no focus steal)"
)
config.addinivalue_line(
"markers", "requires_app: tests that need a real running application"
)
config.addinivalue_line("markers", "slow: performance tests that may take longer")
config.addinivalue_line(
"markers", "integration: tests requiring real macOS accessibility API"
)
@dataclass
class TestApp:
name: str
bundle_id: str
pid: int | None = None
process: subprocess.Popen[bytes] | None = None
def is_running(self) -> bool:
if self.pid is None:
return False
try:
os.kill(self.pid, 0)
return True
except OSError:
return False
def terminate(self) -> None:
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
@pytest.fixture
def calculator_app() -> Generator[TestApp, None, None]:
process = subprocess.Popen(
["open", "-a", "Calculator", "--new"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(1.0)
result = subprocess.run(
["pgrep", "-n", "Calculator"],
capture_output=True,
text=True,
check=False,
)
pid = int(result.stdout.strip()) if result.returncode == 0 else None
app = TestApp(
name="Calculator",
bundle_id="com.apple.calculator",
pid=pid,
process=process,
)
yield app
subprocess.run(
["osascript", "-e", 'tell application "Calculator" to quit'],
capture_output=True,
check=False,
)
time.sleep(0.5)
@pytest.fixture
def textedit_app() -> Generator[TestApp, None, None]:
process = subprocess.Popen(
["open", "-a", "TextEdit", "--new"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(1.0)
result = subprocess.run(
["pgrep", "-n", "TextEdit"],
capture_output=True,
text=True,
check=False,
)
pid = int(result.stdout.strip()) if result.returncode == 0 else None
app = TestApp(
name="TextEdit",
bundle_id="com.apple.TextEdit",
pid=pid,
process=process,
)
yield app
subprocess.run(
["osascript", "-e", 'tell application "TextEdit" to quit saving no'],
capture_output=True,
check=False,
)
time.sleep(0.5)
@pytest.fixture
def finder_app() -> TestApp:
result = subprocess.run(
["pgrep", "-x", "Finder"],
capture_output=True,
text=True,
check=False,
)
pid = int(result.stdout.strip().split("\n")[0]) if result.returncode == 0 else None
return TestApp(
name="Finder",
bundle_id="com.apple.finder",
pid=pid,
)
@dataclass
class MockAXElement:
role: str
title: str | None = None
value: str | None = None
identifier: str | None = None
description: str | None = None
label: str | None = None
enabled: bool = True
focused: bool = False
bounds: tuple[float, float, float, float] | None = None
children: list[MockAXElement] | None = None
parent: MockAXElement | None = None
data_testid: str | None = None
aria_label: str | None = None
def get_children(self) -> list[MockAXElement]:
return self.children or []
def create_calculator_tree() -> MockAXElement:
number_buttons = [
MockAXElement(role="AXButton", title=str(i), identifier=f"calc_btn_{i}")
for i in range(10)
]
operator_buttons = [
MockAXElement(role="AXButton", title="+", identifier="calc_btn_plus"),
MockAXElement(role="AXButton", title="-", identifier="calc_btn_minus"),
MockAXElement(role="AXButton", title="*", identifier="calc_btn_multiply"),
MockAXElement(role="AXButton", title="/", identifier="calc_btn_divide"),
MockAXElement(role="AXButton", title="=", identifier="calc_btn_equals"),
MockAXElement(role="AXButton", title="AC", identifier="calc_btn_clear"),
]
button_group = MockAXElement(
role="AXGroup",
identifier="calculator_buttons",
children=number_buttons + operator_buttons,
)
display = MockAXElement(
role="AXStaticText",
value="0",
identifier="calculator_display",
)
display_group = MockAXElement(
role="AXGroup",
identifier="calculator_display_group",
children=[display],
)
window = MockAXElement(
role="AXWindow",
title="Calculator",
identifier="calculator_window",
children=[display_group, button_group],
)
app = MockAXElement(
role="AXApplication",
title="Calculator",
identifier="com.apple.calculator",
children=[window],
)
return app
@pytest.fixture
def mock_calculator_tree() -> MockAXElement:
return create_calculator_tree()
@pytest.fixture
def mock_ax_element() -> Callable[..., MockAXElement]:
def _create(
role: str = "AXButton",
title: str | None = "Test",
value: str | None = None,
identifier: str | None = None,
**kwargs: Any,
) -> MockAXElement:
return MockAXElement(
role=role,
title=title,
value=value,
identifier=identifier,
**kwargs,
)
return _create
@dataclass
class PerformanceResult:
operation: str
duration_ms: float
iterations: int
min_ms: float
max_ms: float
avg_ms: float
p95_ms: float
def meets_target(self, target_ms: float) -> bool:
return self.p95_ms <= target_ms
@pytest.fixture
def perf_timer() -> Callable[..., PerformanceResult]:
def _measure(
operation: Callable[[], Any],
iterations: int = 100,
warmup: int = 5,
name: str = "operation",
) -> PerformanceResult:
for _ in range(warmup):
try:
operation()
except Exception:
pass
durations: list[float] = []
for _ in range(iterations):
start = time.perf_counter()
try:
operation()
except Exception:
pass
end = time.perf_counter()
durations.append((end - start) * 1000)
durations.sort()
p95_idx = int(iterations * 0.95)
return PerformanceResult(
operation=name,
duration_ms=sum(durations),
iterations=iterations,
min_ms=min(durations),
max_ms=max(durations),
avg_ms=sum(durations) / iterations,
p95_ms=durations[p95_idx] if p95_idx < len(durations) else durations[-1],
)
return _measure
@dataclass
class FocusState:
frontmost_app: str
frontmost_window: str | None
timestamp: float
@pytest.fixture
def focus_tracker() -> Callable[[], FocusState]:
def _get_focus() -> FocusState:
result = subprocess.run(
[
"osascript",
"-e",
'tell application "System Events" to get name of first application process whose frontmost is true',
],
capture_output=True,
text=True,
check=False,
)
frontmost_app = result.stdout.strip() if result.returncode == 0 else "Unknown"
return FocusState(
frontmost_app=frontmost_app,
frontmost_window=None, timestamp=time.time(),
)
return _get_focus
@contextmanager
def verify_no_focus_change(
focus_tracker: Callable[[], FocusState],
) -> Generator[None, None, None]:
before = focus_tracker()
yield
after = focus_tracker()
if before.frontmost_app != after.frontmost_app:
pytest.fail(
f"Focus changed from '{before.frontmost_app}' to '{after.frontmost_app}'"
)
@pytest.fixture
def no_focus_change(
focus_tracker: Callable[[], FocusState],
) -> Callable[[], contextmanager[None]]:
@contextmanager
def _verify() -> Generator[None, None, None]:
with verify_no_focus_change(focus_tracker):
yield
return _verify
@pytest.fixture
def mock_accessibility_disabled() -> Generator[MagicMock, None, None]:
with patch("axterminator.is_accessibility_enabled", return_value=False) as mock:
yield mock
@pytest.fixture
def mock_accessibility_enabled() -> Generator[MagicMock, None, None]:
with patch("axterminator.is_accessibility_enabled", return_value=True) as mock:
yield mock
@pytest.fixture
def mock_app_connect() -> Generator[MagicMock, None, None]:
def _mock_connect(
name: str | None = None,
bundle_id: str | None = None,
pid: int | None = None,
) -> MagicMock:
mock_app = MagicMock()
mock_app.pid = pid or 12345
mock_app.bundle_id = bundle_id
mock_app.name = name
mock_app.is_running.return_value = True
return mock_app
with patch("axterminator.app", side_effect=_mock_connect) as mock:
yield mock
@pytest.fixture
def sample_queries() -> list[str]:
return [
"Button",
"role:AXButton",
"title:Save",
"identifier:btn_save",
"role:AXButton title:Save",
"//AXWindow/AXButton[@title='Save']",
]
@pytest.fixture
def healing_strategies() -> list[str]:
return [
"data_testid",
"aria_label",
"identifier",
"title",
"xpath",
"position",
"visual_vlm",
]
def has_accessibility_permission() -> bool:
try:
import axterminator
return axterminator.is_accessibility_enabled()
except (ImportError, AttributeError):
return False
skip_without_accessibility = pytest.mark.skipif(
not has_accessibility_permission(),
reason="Requires accessibility permissions",
)
skip_in_ci = pytest.mark.skipif(
os.environ.get("CI") == "true",
reason="Cannot run in CI environment",
)
@pytest.fixture
def event_loop_policy():
import asyncio
return asyncio.DefaultEventLoopPolicy()