Skip to content

Commit d229032

Browse files
committed
Add in-memory fake backend for CI smoke tests
install_fake_backend() / uninstall_fake_backend() swap the mouse / keyboard / screen / clipboard wrappers with recorders that mutate an in-memory FakeState rather than the real OS. Lets a CI runner exercise every MCP tool end-to-end without a display server. Toggle via JE_AUTOCONTROL_FAKE_BACKEND=1 or the new --fake-backend CLI flag. ac_click_mouse adapter relaxed to pass through string keycodes unchanged so it works under either backend.
1 parent 91a0857 commit d229032

5 files changed

Lines changed: 284 additions & 4 deletions

File tree

je_auto_control/utils/mcp_server/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from je_auto_control.utils.mcp_server.context import (
1414
OperationCancelledError, ToolCallContext,
1515
)
16+
from je_auto_control.utils.mcp_server.fake_backend import (
17+
FakeState, fake_state, install_fake_backend, reset_fake_state,
18+
uninstall_fake_backend,
19+
)
1620
from je_auto_control.utils.mcp_server.rate_limit import RateLimiter
1721
from je_auto_control.utils.mcp_server.http_transport import (
1822
HttpMCPServer, start_mcp_http_server,
@@ -29,12 +33,14 @@
2933
)
3034

3135
__all__ = [
32-
"AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt",
36+
"AuditLogger", "FakeState", "HttpMCPServer", "MCPContent", "MCPPrompt",
3337
"MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool",
3438
"MCPToolAnnotations", "OperationCancelledError", "PromptProvider",
3539
"RateLimiter", "ResourceProvider", "ToolCallContext",
3640
"build_default_tool_registry",
3741
"default_prompt_provider", "default_resource_provider",
38-
"make_plugin_tool", "register_plugin_tools",
42+
"fake_state", "install_fake_backend", "make_plugin_tool",
43+
"register_plugin_tools", "reset_fake_state",
3944
"start_mcp_http_server", "start_mcp_stdio_server",
45+
"uninstall_fake_backend",
4046
]

je_auto_control/utils/mcp_server/__main__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import json
99
import sys
1010

11+
from je_auto_control.utils.mcp_server.fake_backend import (
12+
install_fake_backend, maybe_install_from_env,
13+
)
1114
from je_auto_control.utils.mcp_server.prompts import default_prompt_provider
1215
from je_auto_control.utils.mcp_server.resources import (
1316
default_resource_provider,
@@ -39,13 +42,22 @@ def _build_parser() -> argparse.ArgumentParser:
3942
"--read-only", action="store_true",
4043
help="Restrict tools to those marked readOnlyHint=true.",
4144
)
45+
parser.add_argument(
46+
"--fake-backend", action="store_true",
47+
help=("Install the in-memory fake backend so tools record but "
48+
"don't drive the real OS. Useful for CI smoke tests."),
49+
)
4250
return parser
4351

4452

4553
def main(argv: list = None) -> int:
4654
"""CLI entry point. Returns the process exit code."""
4755
parser = _build_parser()
4856
args = parser.parse_args(argv)
57+
if args.fake_backend:
58+
install_fake_backend()
59+
else:
60+
maybe_install_from_env()
4961
listing_modes = (args.list_tools, args.list_resources, args.list_prompts)
5062
if any(listing_modes):
5163
_print_listings(args)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""In-memory fake backend for CI / headless tool tests.
2+
3+
Drop-in replacement for the wrapper layer's mouse / keyboard / screen
4+
calls that records every invocation rather than touching the real OS.
5+
Activate via :func:`install_fake_backend` (or set
6+
``JE_AUTOCONTROL_FAKE_BACKEND=1`` before starting the MCP server) so
7+
test agents can drive the full tool registry on a CI runner without a
8+
display server.
9+
"""
10+
import os
11+
import threading
12+
from dataclasses import dataclass, field
13+
from typing import Any, Dict, List, Tuple
14+
15+
16+
@dataclass
17+
class FakeState:
18+
"""Records what the model would have done if the OS were real."""
19+
20+
cursor: Tuple[int, int] = (0, 0)
21+
screen_size: Tuple[int, int] = (1920, 1080)
22+
clipboard_text: str = ""
23+
typed_text: List[str] = field(default_factory=list)
24+
keys_pressed: List[Any] = field(default_factory=list)
25+
mouse_actions: List[Tuple[str, Any, ...]] = field(default_factory=list)
26+
27+
28+
def fake_state() -> FakeState:
29+
"""Return the process-wide fake state."""
30+
return _STATE
31+
32+
33+
_STATE = FakeState()
34+
_STATE_LOCK = threading.Lock()
35+
36+
37+
def reset_fake_state() -> None:
38+
"""Reset every recorded interaction. Useful between tests."""
39+
global _STATE
40+
with _STATE_LOCK:
41+
_STATE = FakeState()
42+
43+
44+
# === Patched callables ======================================================
45+
46+
def _fake_get_mouse_position() -> Tuple[int, int]:
47+
return _STATE.cursor
48+
49+
50+
def _fake_set_mouse_position(x: int, y: int) -> Tuple[int, int]:
51+
with _STATE_LOCK:
52+
_STATE.cursor = (int(x), int(y))
53+
_STATE.mouse_actions.append(("set_position", int(x), int(y)))
54+
return _STATE.cursor
55+
56+
57+
def _fake_click_mouse(mouse_keycode: Any, x: Any = None,
58+
y: Any = None) -> Tuple[Any, int, int]:
59+
cx, cy = _STATE.cursor if x is None or y is None else (int(x), int(y))
60+
with _STATE_LOCK:
61+
_STATE.cursor = (cx, cy)
62+
_STATE.mouse_actions.append(("click", mouse_keycode, cx, cy))
63+
return mouse_keycode, cx, cy
64+
65+
66+
def _fake_press_mouse(mouse_keycode: Any, x: Any = None,
67+
y: Any = None) -> Tuple[Any, int, int]:
68+
cx, cy = _STATE.cursor if x is None or y is None else (int(x), int(y))
69+
with _STATE_LOCK:
70+
_STATE.mouse_actions.append(("press", mouse_keycode, cx, cy))
71+
return mouse_keycode, cx, cy
72+
73+
74+
def _fake_release_mouse(mouse_keycode: Any, x: Any = None,
75+
y: Any = None) -> Tuple[Any, int, int]:
76+
cx, cy = _STATE.cursor if x is None or y is None else (int(x), int(y))
77+
with _STATE_LOCK:
78+
_STATE.mouse_actions.append(("release", mouse_keycode, cx, cy))
79+
return mouse_keycode, cx, cy
80+
81+
82+
def _fake_mouse_scroll(scroll_value: int, x: Any = None, y: Any = None,
83+
scroll_direction: str = "scroll_down"
84+
) -> Tuple[int, str]:
85+
with _STATE_LOCK:
86+
_STATE.mouse_actions.append(
87+
("scroll", int(scroll_value), scroll_direction),
88+
)
89+
return int(scroll_value), scroll_direction
90+
91+
92+
def _fake_screen_size() -> Tuple[int, int]:
93+
return _STATE.screen_size
94+
95+
96+
def _fake_write(text: str, *_args, **_kwargs) -> str:
97+
with _STATE_LOCK:
98+
_STATE.typed_text.append(text)
99+
return text
100+
101+
102+
def _fake_type_keyboard(keycode: Any, *_args, **_kwargs) -> str:
103+
with _STATE_LOCK:
104+
_STATE.keys_pressed.append(keycode)
105+
return str(keycode)
106+
107+
108+
def _fake_hotkey(keys: List[Any], *_args, **_kwargs) -> Tuple[str, str]:
109+
joined = ",".join(str(k) for k in keys)
110+
with _STATE_LOCK:
111+
_STATE.keys_pressed.append(("hotkey", joined))
112+
return joined, joined
113+
114+
115+
def _fake_get_clipboard() -> str:
116+
return _STATE.clipboard_text
117+
118+
119+
def _fake_set_clipboard(text: str) -> None:
120+
with _STATE_LOCK:
121+
_STATE.clipboard_text = str(text)
122+
123+
124+
# === Install / uninstall ====================================================
125+
126+
_INSTALLED: Dict[str, Any] = {}
127+
128+
129+
def install_fake_backend() -> None:
130+
"""Replace the headless API entry points with the fake recorders."""
131+
if _INSTALLED:
132+
return
133+
from je_auto_control.utils.clipboard import clipboard as clipboard_module
134+
from je_auto_control.wrapper import auto_control_keyboard as kbd_module
135+
from je_auto_control.wrapper import auto_control_mouse as mouse_module
136+
from je_auto_control.wrapper import auto_control_screen as screen_module
137+
targets: Dict[Any, Dict[str, Any]] = {
138+
mouse_module: {
139+
"get_mouse_position": _fake_get_mouse_position,
140+
"set_mouse_position": _fake_set_mouse_position,
141+
"click_mouse": _fake_click_mouse,
142+
"press_mouse": _fake_press_mouse,
143+
"release_mouse": _fake_release_mouse,
144+
"mouse_scroll": _fake_mouse_scroll,
145+
},
146+
screen_module: {"screen_size": _fake_screen_size},
147+
kbd_module: {
148+
"write": _fake_write,
149+
"type_keyboard": _fake_type_keyboard,
150+
"hotkey": _fake_hotkey,
151+
},
152+
clipboard_module: {
153+
"get_clipboard": _fake_get_clipboard,
154+
"set_clipboard": _fake_set_clipboard,
155+
},
156+
}
157+
for module, replacements in targets.items():
158+
for name, replacement in replacements.items():
159+
key = f"{module.__name__}.{name}"
160+
_INSTALLED[key] = (module, name, getattr(module, name))
161+
setattr(module, name, replacement)
162+
163+
164+
def uninstall_fake_backend() -> None:
165+
"""Restore the real backend functions previously replaced."""
166+
while _INSTALLED:
167+
_key, value = _INSTALLED.popitem()
168+
module, name, original = value
169+
setattr(module, name, original)
170+
171+
172+
def maybe_install_from_env() -> bool:
173+
"""Install the fake backend when ``JE_AUTOCONTROL_FAKE_BACKEND`` is truthy."""
174+
raw = os.environ.get("JE_AUTOCONTROL_FAKE_BACKEND", "").strip().lower()
175+
if raw in {"1", "true", "yes", "on"}:
176+
install_fake_backend()
177+
return True
178+
return False
179+
180+
181+
__all__ = [
182+
"FakeState", "fake_state", "install_fake_backend",
183+
"maybe_install_from_env", "reset_fake_state", "uninstall_fake_backend",
184+
]

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717

1818
def click_mouse(mouse_keycode: str = "mouse_left",
1919
x: Optional[int] = None,
20-
y: Optional[int] = None) -> List[int]:
20+
y: Optional[int] = None) -> List[Any]:
2121
from je_auto_control.wrapper.auto_control_mouse import click_mouse as _click
2222
keycode, click_x, click_y = _click(mouse_keycode, x, y)
23-
return [int(keycode), int(click_x), int(click_y)]
23+
# Real wrapper resolves the string keycode to an int via the keys table;
24+
# the fake backend keeps it as a string. Pass through whatever we got.
25+
resolved = int(keycode) if isinstance(keycode, int) else keycode
26+
return [resolved, int(click_x), int(click_y)]
2427

2528

2629
def set_mouse_position(x: int, y: int) -> List[int]:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Tests for the fake backend used in CI smoke runs."""
2+
import pytest
3+
4+
from je_auto_control.utils.mcp_server.fake_backend import (
5+
fake_state, install_fake_backend, maybe_install_from_env,
6+
reset_fake_state, uninstall_fake_backend,
7+
)
8+
from je_auto_control.utils.mcp_server.tools import (
9+
build_default_tool_registry,
10+
)
11+
12+
13+
@pytest.fixture()
14+
def fake_backend():
15+
"""Install the fake backend for the duration of the test."""
16+
reset_fake_state()
17+
install_fake_backend()
18+
yield
19+
uninstall_fake_backend()
20+
reset_fake_state()
21+
22+
23+
def test_set_mouse_position_records_in_fake_state(fake_backend):
24+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
25+
by_name["ac_set_mouse_position"].invoke({"x": 100, "y": 200})
26+
assert fake_state().cursor == (100, 200)
27+
assert ("set_position", 100, 200) in fake_state().mouse_actions
28+
29+
30+
def test_click_mouse_records_button_and_coords(fake_backend):
31+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
32+
by_name["ac_click_mouse"].invoke({
33+
"mouse_keycode": "mouse_left", "x": 50, "y": 60,
34+
})
35+
assert ("click", "mouse_left", 50, 60) in fake_state().mouse_actions
36+
37+
38+
def test_clipboard_round_trip_via_fake_backend(fake_backend):
39+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
40+
by_name["ac_set_clipboard"].invoke({"text": "hi"})
41+
assert by_name["ac_get_clipboard"].invoke({}) == "hi"
42+
assert fake_state().clipboard_text == "hi"
43+
44+
45+
def test_type_text_appends_to_typed_history(fake_backend):
46+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
47+
by_name["ac_type_text"].invoke({"text": "hello"})
48+
assert "hello" in fake_state().typed_text
49+
50+
51+
def test_install_is_idempotent(fake_backend):
52+
install_fake_backend()
53+
install_fake_backend()
54+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
55+
by_name["ac_set_mouse_position"].invoke({"x": 1, "y": 2})
56+
assert fake_state().cursor == (1, 2)
57+
58+
59+
def test_maybe_install_from_env_respects_flag(monkeypatch):
60+
reset_fake_state()
61+
uninstall_fake_backend()
62+
monkeypatch.setenv("JE_AUTOCONTROL_FAKE_BACKEND", "1")
63+
try:
64+
assert maybe_install_from_env() is True
65+
from je_auto_control.wrapper import auto_control_mouse as mouse_module
66+
moved = mouse_module.set_mouse_position(7, 9)
67+
assert moved == (7, 9)
68+
assert fake_state().cursor == (7, 9)
69+
finally:
70+
uninstall_fake_backend()
71+
72+
73+
def test_maybe_install_from_env_skips_when_unset(monkeypatch):
74+
monkeypatch.delenv("JE_AUTOCONTROL_FAKE_BACKEND", raising=False)
75+
assert maybe_install_from_env() is False

0 commit comments

Comments
 (0)