autocad-bench / tests /test_windows_input_driver.py
Dev Mandal
Initial open-source release
0be8f22
Raw
History Blame Contribute Delete
22.6 kB
from __future__ import annotations
import ctypes
import importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
WINDOWS_SANDBOX_ROOT = ROOT / "sandbox" / "windows"
DRIVER_PATH = WINDOWS_SANDBOX_ROOT / "broker" / "pyautogui_driver.py"
BROKER_SOURCE_PATH = WINDOWS_SANDBOX_ROOT / "broker" / "AutoCADBench.Broker.cs"
READINESS_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "verify-boot-readiness.ps1"
START_BROKER_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "start-broker.ps1"
RECOVERY_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "recover-boot-readiness.ps1"
DEPLOY_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "deploy-broker-update.ps1"
CLEAN_IMAGE_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "clean-for-image.ps1"
INSTALL_BOOT_PATH = WINDOWS_SANDBOX_ROOT / "bootstrap" / "install-boot-automation.ps1"
INTERACTIVE_EVALUATOR_PATH = (
WINDOWS_SANDBOX_ROOT / "evaluator" / "run-interactive-evaluation.ps1"
)
class FakePyAutoGUI(ModuleType):
def __init__(self) -> None:
super().__init__("pyautogui")
self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = []
self.FAILSAFE = True
self.PAUSE = 0.0
self.cursor = (1200, 700)
self.ignored_moves = 0
def _record(self, name: str, *args: object, **kwargs: object) -> None:
self.calls.append((name, args, kwargs))
def size(self) -> tuple[int, int]:
return 1920, 1080
def position(self) -> tuple[int, int]:
return self.cursor
def click(self, *args: object, **kwargs: object) -> None:
self._record("click", *args, **kwargs)
def doubleClick(self, *args: object, **kwargs: object) -> None:
self._record("doubleClick", *args, **kwargs)
def moveTo(self, *args: object, **kwargs: object) -> None:
self._record("moveTo", *args, **kwargs)
if self.ignored_moves:
self.ignored_moves -= 1
return
self.cursor = (int(args[0]), int(args[1]))
def mouseDown(self, *args: object, **kwargs: object) -> None:
self._record("mouseDown", *args, **kwargs)
def mouseUp(self, *args: object, **kwargs: object) -> None:
self._record("mouseUp", *args, **kwargs)
def hscroll(self, *args: object, **kwargs: object) -> None:
self._record("hscroll", *args, **kwargs)
def scroll(self, *args: object, **kwargs: object) -> None:
self._record("scroll", *args, **kwargs)
def hotkey(self, *args: object, **kwargs: object) -> None:
self._record("hotkey", *args, **kwargs)
def press(self, *args: object, **kwargs: object) -> None:
self._record("press", *args, **kwargs)
def write(self, *args: object, **kwargs: object) -> None:
self._record("write", *args, **kwargs)
@pytest.fixture
def driver(monkeypatch: pytest.MonkeyPatch):
fake = FakePyAutoGUI()
monkeypatch.setitem(sys.modules, "pyautogui", fake)
monkeypatch.setattr(
ctypes,
"windll",
SimpleNamespace(user32=SimpleNamespace(), kernel32=SimpleNamespace()),
raising=False,
)
spec = importlib.util.spec_from_file_location("autocad_pyautogui_driver", DRIVER_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module, fake
def test_driver_maps_coordinate_and_keyboard_actions(driver) -> None:
module, fake = driver
module._execute({"action": "click", "x": 600, "y": 400, "button": "left"})
module._execute({"action": "key", "keys": ["CTRL", "S"]})
module._execute({"action": "key", "keys": ["ENTER"]})
module._execute({"action": "type", "text": "_LINE 10,20"})
module._execute({"action": "scroll", "dx": -120, "dy": 240})
assert fake.calls == [
("moveTo", (600, 400), {"duration": 0.05}),
("click", (), {"button": "left"}),
("hotkey", ("ctrl", "s"), {"interval": 0.03}),
("press", ("enter",), {}),
("write", ("_LINE 10,20",), {"interval": 0.05}),
("hscroll", (-1,), {}),
("scroll", (2,), {}),
]
def test_driver_maps_full_drag_path(driver) -> None:
module, fake = driver
module._execute(
{
"action": "drag",
"button": "left",
"path": [
{"x": 10, "y": 20},
{"x": 30, "y": 40},
{"x": 50, "y": 60},
],
}
)
assert fake.calls == [
("moveTo", (10, 20), {"duration": 0.05}),
("mouseDown", (), {"button": "left"}),
("moveTo", (30, 40), {"duration": 0.03}),
("moveTo", (50, 60), {"duration": 0.03}),
("mouseUp", (), {"button": "left"}),
]
def test_driver_retries_cursor_move_before_emitting_click(driver) -> None:
module, fake = driver
fake.ignored_moves = 1
module._execute({"action": "click", "x": 67, "y": 36, "button": "left"})
assert fake.calls == [
("moveTo", (67, 36), {"duration": 0.05}),
("moveTo", (67, 36), {"duration": 0.05}),
("click", (), {"button": "left"}),
]
def test_driver_exhausts_ten_cursor_moves_without_clicking(driver) -> None:
module, fake = driver
fake.ignored_moves = module.CURSOR_MOVE_ATTEMPTS
with pytest.raises(module.DeliveryError, match="after 10 moves"):
module._execute({"action": "click", "x": 67, "y": 36, "button": "left"})
assert [call[0] for call in fake.calls] == [
"moveTo"
] * module.CURSOR_MOVE_ATTEMPTS
def test_driver_rejects_non_ascii_typing_and_unknown_keys(driver) -> None:
module, _ = driver
with pytest.raises(module.DeliveryError, match="printable ASCII"):
module._execute({"action": "type", "text": "LINE\n"})
with pytest.raises(module.DeliveryError, match="allowlist"):
module._execute({"action": "key", "keys": ["WIN"]})
def test_driver_allows_literal_printable_space(driver) -> None:
module, fake = driver
module._execute({"action": "type", "text": " "})
assert fake.calls == [("write", (" ",), {"interval": 0.05})]
def test_driver_follows_foreground_dialog_after_initial_handoff(
driver, monkeypatch: pytest.MonkeyPatch
) -> None:
module, _ = driver
monkeypatch.setattr(
module,
"_desktop_identity",
lambda: {
"driver_version": module.DRIVER_VERSION,
"python_pid": 10,
"session_id": 1,
"window_station": "WinSta0",
"desktop": "Default",
"screen_width": 1920,
"screen_height": 1080,
},
)
target = {
"foreground_hwnd": 100,
"foreground_thread_id": 11,
"foreground_process_id": 456,
"foreground_title": "Save Drawing As",
"foreground_class": "#32770",
"active_hwnd": 100,
"focused_hwnd": 101,
"focused_thread_id": 11,
"focused_process_id": 456,
"focused_title": "File name",
"focused_class": "Edit",
"focus_matches_foreground_process": True,
}
monkeypatch.setattr(module, "_input_focus_snapshot", lambda: target)
receipt = module._handle(
{
"action": {"action": "key", "keys": ["ENTER"]},
"expected_session_id": 1,
"expected_width": 1920,
"expected_height": 1080,
"expected_foreground_pid": 123,
}
)
assert receipt["ok"] is True
assert receipt["foreground_before"] == 456
assert receipt["foreground_was_autocad"] is False
assert receipt["focus_was_autocad"] is False
assert receipt["keyboard_target_verified"] is True
assert receipt["target_before"]["focused_class"] == "Edit"
def test_driver_injects_and_reports_when_no_child_has_focus(
driver, monkeypatch: pytest.MonkeyPatch
) -> None:
module, _ = driver
monkeypatch.setattr(
module,
"_desktop_identity",
lambda: {
"driver_version": module.DRIVER_VERSION,
"python_pid": 10,
"session_id": 1,
"window_station": "WinSta0",
"desktop": "Default",
"screen_width": 1920,
"screen_height": 1080,
},
)
monkeypatch.setattr(
module,
"_input_focus_snapshot",
lambda: {
"foreground_hwnd": 100,
"foreground_thread_id": 11,
"foreground_process_id": 123,
"foreground_title": "Autodesk AutoCAD 2019",
"foreground_class": "Afx:00400000",
"active_hwnd": 100,
"focused_hwnd": 0,
"focused_thread_id": 0,
"focused_process_id": 0,
"focused_title": "",
"focused_class": "",
"focus_matches_foreground_process": False,
},
)
receipt = module._handle(
{
"action": {"action": "type", "text": "LINE"},
"expected_session_id": 1,
"expected_width": 1920,
"expected_height": 1080,
"expected_foreground_pid": 123,
}
)
assert receipt["ok"] is True
assert receipt["foreground_was_autocad"] is True
assert receipt["keyboard_target_verified"] is False
assert receipt["keyboard_target_mode"] == "no_focused_control"
def test_driver_injects_and_reports_keyboard_focused_by_another_process(
driver, monkeypatch: pytest.MonkeyPatch
) -> None:
module, fake = driver
monkeypatch.setattr(
module,
"_desktop_identity",
lambda: {
"driver_version": module.DRIVER_VERSION,
"python_pid": 10,
"session_id": 1,
"window_station": "WinSta0",
"desktop": "Default",
"screen_width": 1920,
"screen_height": 1080,
},
)
monkeypatch.setattr(
module,
"_input_focus_snapshot",
lambda: {
"foreground_hwnd": 100,
"foreground_thread_id": 11,
"foreground_process_id": 123,
"foreground_title": "Autodesk AutoCAD 2019",
"foreground_class": "AfxFrame",
"active_hwnd": 100,
"focused_hwnd": 200,
"focused_thread_id": 12,
"focused_process_id": 999,
"focused_title": "Foreign",
"focused_class": "Edit",
"focus_matches_foreground_process": False,
},
)
receipt = module._handle(
{
"action": {"action": "type", "text": "LINE"},
"expected_session_id": 1,
"expected_width": 1920,
"expected_height": 1080,
"expected_foreground_pid": 123,
}
)
assert receipt["ok"] is True
assert receipt["keyboard_target_verified"] is False
assert receipt["keyboard_target_mode"] == "cross_process_focus"
assert fake.calls == [("write", ("LINE",), {"interval": 0.05})]
def test_driver_reads_gui_thread_focus_and_window_metadata(
driver, monkeypatch: pytest.MonkeyPatch
) -> None:
module, _ = driver
class FakeUser32:
@staticmethod
def GetForegroundWindow() -> int:
return 100
@staticmethod
def GetWindowThreadProcessId(window, process_id) -> int:
handle = int(window.value)
process_id._obj.value = 123 if handle in {100, 101} else 0
return 11 if handle in {100, 101} else 0
@staticmethod
def GetGUIThreadInfo(thread_id, info) -> bool:
assert thread_id == 11
info._obj.hwndActive = 100
info._obj.hwndFocus = 101
return True
@staticmethod
def GetWindowTextLengthW(window) -> int:
return len("AutoCAD" if int(window.value) == 100 else "Model space")
@staticmethod
def GetWindowTextW(window, buffer, size) -> int:
value = "AutoCAD" if int(window.value) == 100 else "Model space"
buffer.value = value[: size - 1]
return len(buffer.value)
@staticmethod
def GetClassNameW(window, buffer, size) -> int:
value = "AfxFrame" if int(window.value) == 100 else "AfxView"
buffer.value = value[: size - 1]
return len(buffer.value)
monkeypatch.setattr(module, "_USER32", FakeUser32())
target = module._input_focus_snapshot()
assert target == {
"foreground_hwnd": 100,
"foreground_thread_id": 11,
"foreground_process_id": 123,
"foreground_title": "AutoCAD",
"foreground_class": "AfxFrame",
"active_hwnd": 100,
"focused_hwnd": 101,
"focused_thread_id": 11,
"focused_process_id": 123,
"focused_title": "Model space",
"focused_class": "AfxView",
"focus_matches_foreground_process": True,
}
def test_broker_uses_dedicated_worker_reader_and_bounded_autocad_retry() -> None:
source = BROKER_SOURCE_PATH.read_text()
readiness = READINESS_PATH.read_text()
startup = START_BROKER_PATH.read_text()
assert "Task<string>.Factory.StartNew" not in source
assert 'reader.Name = "AutoCADBench-PyAutoGUI-stdout"' in source
assert 'input_driver_ready = true' in source
assert '{ "action", "move" }' in source
assert 'ReadLine(120000, "startup handshake")' in source
assert 'ReadLine(60000, "action response")' in source
assert 'broker_version = "windows-autocad-2019-v11"' in source
assert 'RequiredIdentifier(action, "request_id")' in source
assert 'RequiredInt(action, "expected_action_index")' in source
assert 'string text = RequiredText(actionPayload, "text");' in source
required_text = source[
source.index("private static string RequiredText") : source.index(
"private static string RequiredString"
)
]
assert "text.Length == 0" in required_text
assert "IsNullOrWhiteSpace" not in required_text
assert "session.ActionResults.TryGetValue" in source
assert "WriteActionResponse(context, session, cachedResult, true);" in source
assert 'driverAction.Remove("intent");' in source
assert 'input_driver = "pyautogui-0.9.54-v3"' in source
assert "AutoCADStartupTimeoutSeconds = 120" in source
assert "AutoCADHandoffAttemptLimit = 2" in source
assert "PrepareAutoCADHandoffWithRecovery(session);" in source
assert "handoffAttempt <= AutoCADHandoffAttemptLimit" in source
assert "StopTimedOutAutoCADProcess(session.AutoCADProcess);" in source
assert "File.Copy(_template, session.ArtifactPath, true);" in source
assert "autocad_launch_attempts = session.AutoCADLaunchAttempts" in source
assert "handoff_attempts = session.HandoffAttempts" in source
assert 'exception.Code == "desktop_unstable"' in source
assert "EnsureCleanForegroundHandoff(session);" in source
assert "TryGetVisiblePopup" in source
assert "A non-AutoCAD window owns the foreground at handoff" in source
assert '"autocad_restart_failed"' in source
release = source[source.index("private static void Release"):source.index("private static object CaptureResponse")]
assert "WaitForExit(AutoCADRelaunchStopTimeoutMs)" in release
assert "StopAutoCADProcesses();" in release
assert "WaitForVisualStability(" in source
assert "WaitForRenderedRibbon(" in source
assert "HandoffRenderedRibbonComparisons = 2" in source
assert "ActionStableComparisons = 5" in source
assert "IsRibbonRendered(" in source
assert "VerifyKeyboardHandoff(session);" not in source
checkpoint = source[
source.index("private static void Checkpoint") : source.index(
"private static void RetrieveArtifact"
)
]
assert "SaveActiveDocumentToControlledPath(session);" in checkpoint
assert 'Marshal.GetActiveObject("AutoCAD.Application.23.0")' in checkpoint
assert '"controller-final.dwg"' in checkpoint
assert 'new object[] { "CTRL", "S" }' not in checkpoint
assert "after 5 attempts" in checkpoint
prepare = source[source.index("private static void PrepareInitialDesktop"):source.index("private static void FocusAutoCAD")]
assert prepare.index("DeliverTrustedInput(session, canvasClick);") < prepare.index('{ "keys", new object[] { "ESC" } }')
assert 'inputReceipt["post_action_visual_stable"] = visualStable;' in source
assert "visualStable = false;" in source
assert "captured = CaptureDesktopBitmap();" in source
driver_source = DRIVER_PATH.read_text()
assert "GetGUIThreadInfo" in driver_source
assert '"keyboard_target_verified"' in driver_source
assert '"cross_process_focus"' in driver_source
assert '"no_focused_control"' in driver_source
assert "keyboard target is unsafe" not in driver_source
assert "time.sleep(0.2)" not in driver_source
assert "-TimeoutSec 600" in readiness
assert "autocad_launch_attempts = [int]$reset.autocad_launch_attempts" in readiness
assert "handoff_attempts = [int]$reset.handoff_attempts" in readiness
assert "handoff_probe" not in readiness
assert '"windows-autocad-2019-v11"' in readiness
assert '"pyautogui-0.9.54-v3"' in readiness
assert '"windows-autocad-2019-v11"' in startup
assert '"pyautogui-0.9.54-v3"' in startup
assert "function Get-VerifiedDeployment" in startup
assert 'Join-Path $BenchRoot "installed-manifest.json"' in startup
assert "deployment_id = $deployment.deployment_id" in startup
assert "manifest_sha256 = $deployment.manifest_sha256" in startup
assert "function Set-DcvLayoutWithRetry" in startup
assert "[int]$AttemptLimit = 6" in startup
assert "WaitForExit(15000)" in startup
assert "set-display-layout" in startup
assert "dcv_layout_attempts = $dcvLayoutAttempts" in startup
assert "dcv_layout_attempts = [int]$ready.dcv_layout_attempts" in readiness
assert "$ready.broker_pid -ne $broker.Id" in readiness
assert "$health.broker_pid -ne $broker.Id" in readiness
assert "$ready.startup_id" in readiness
assert "$ready.broker_sha256" in readiness
assert "$liveBrokerSha256" in readiness
assert "$liveManifestSha256" in readiness
assert '$task.State.ToString() -ne "Running"' in readiness
assert "$startupState.startup_id -ne $ready.startup_id" in readiness
assert "Reset smoke passed but session release failed" in readiness
def test_boot_gate_requires_current_boot_readiness() -> None:
source = READINESS_PATH.read_text()
assert "$readyBootTime" in source
assert "$bootTime" in source
assert 'readiness files belong to an earlier boot' in source
assert "$candidateHealth.input_driver_ready" in source
def test_boot_recovery_is_bounded_diagnostic_and_deployed_with_broker() -> None:
recovery = RECOVERY_PATH.read_text()
deploy = DEPLOY_PATH.read_text()
assert "pre-recovery.json" in recovery
assert '"startup-state.json", "startup-error.json", "ready.json"' in recovery
assert '"broker.token"' not in recovery
assert "function Stop-BenchmarkProcessTree" in recovery
assert "taskkill.exe /PID $process.Id /T /F" in recovery
assert "post-pass process census decide success" in recovery
assert "Interactive broker task did not stop before recovery cleanup" in recovery
assert "Interactive broker task did not enter Running state after recovery" in recovery
assert "cleanup_passes = $cleanupPasses" in recovery
assert "Start-ScheduledTask -TaskName $TaskName" in recovery
assert "recover-boot-readiness.ps1" in deploy
assert 'Join-Path $BenchRoot "diagnostics"' in CLEAN_IMAGE_PATH.read_text()
def test_broker_lease_and_reset_are_replay_safe() -> None:
source = BROKER_SOURCE_PATH.read_text()
readiness = READINESS_PATH.read_text()
lease = source[
source.index("private static void Lease") : source.index(
"private static void Reset"
)
]
reset = source[
source.index("private static void Reset") : source.index(
"private static void ExecuteAction"
)
]
assert "active.TaskId == taskId && active.AttemptId == attemptId" in lease
assert "WriteJson(context.Response, 200, SessionResponse(active));" in lease
assert 'RequiredIdentifier(payload, "request_id")' in reset
assert "session.ResetResponse != null" in reset
assert "session.ResetRequestId != requestId" in reset
assert "session.ResetResponse = CaptureResponse(session);" in reset
assert 'request_id = "reset-$sessionId"' in readiness
def test_deployment_and_image_cleaning_fail_closed_on_mixed_v10_files() -> None:
deploy = DEPLOY_PATH.read_text()
startup = START_BROKER_PATH.read_text()
clean = CLEAN_IMAGE_PATH.read_text()
install = INSTALL_BOOT_PATH.read_text()
assert 'Join-Path $BenchRoot "installed-manifest.json"' in deploy
assert 'schema_version = "1"' in deploy
assert 'broker_version = "windows-autocad-2019-v11"' in deploy
assert 'relative="bootstrap\\clean-for-image.ps1"' in deploy
assert 'relative="evaluator\\AutoCADBench.Plugin.dll"' in deploy
assert 'relative="evaluator\\AutoCADBench.Plugin.cs"' in deploy
assert 'relative="evaluator\\run-evaluation.ps1"' in deploy
assert 'relative="evaluator\\run-interactive-evaluation.ps1"' in deploy
assert '"evaluator_plugin"' in startup
assert '"evaluator_plugin"' in clean
assert '"evaluator_plugin"' in install
assert ".autocad-bench-new-$deploymentId" in deploy
assert "$swapped.Count - 1" in deploy
assert "throw $deploymentFailure" in deploy
assert '"clean_for_image"' in startup
assert '"clean_for_image"' in clean
assert "Installed deployment checksum mismatch" in clean
assert "Benchmark processes remain after image cleanup" in clean
assert 'Join-Path $BenchRoot "updates"' in clean
assert "UpdateFiles" in clean
assert 'Join-Path $BenchRoot "evaluations"' in clean
assert 'Join-Path $BenchRoot "evaluator-validation"' in clean
assert "EvaluationFiles" in clean
assert "EvaluatorValidationFiles" in clean
assert 'schema_version = "1"' in install
assert 'broker_version = "windows-autocad-2019-v11"' in install
def test_evaluator_uses_the_logged_in_interactive_desktop() -> None:
script = INTERACTIVE_EVALUATOR_PATH.read_text()
assert "-LogonType Interactive" in script
assert "run-evaluation.ps1" in script
assert "interactive-status.json" in script
assert "MetadataUploadUrl" in script
assert "Invoke-WebRequest" in script
assert "Unregister-ScheduledTask" in script