agent-desktop/tests/ffi-python/smoke.py
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

302 lines
11 KiB
Python

"""
FFI smoke harness: proves the C ABI works from a non-Rust host (Python ctypes).
Usage:
python3 tests/ffi-python/smoke.py <dylib_path> <header_path>
Environment variables (override positional args):
AD_DYLIB_PATH path to libagent_desktop_ffi.{dylib,so,dll}
AD_HEADER_PATH path to crates/ffi/include/agent_desktop.h
Exit codes:
0 all legs passed
1 assertion failure or load error (message printed to stderr)
"""
import ctypes
import json
import os
import re
import sys
from ctypes import (
POINTER,
c_bool,
c_char_p,
c_int,
c_size_t,
c_uint,
c_uint8,
c_void_p,
byref,
)
from pathlib import Path
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def fail(msg: str) -> None:
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(1)
def ok(msg: str) -> None:
print(f" ok: {msg}")
def bind(lib: ctypes.CDLL, name: str, restype, argtypes: list) -> ctypes.CFUNCTYPE:
"""Bind one symbol; raises AttributeError with a clear name if missing."""
try:
fn = getattr(lib, name)
except AttributeError:
fail(f"symbol not found in dylib: {name}")
fn.restype = restype
fn.argtypes = argtypes
return fn
def parse_header_constants(header_path: str) -> dict:
"""
Parse numeric #define constants from the header.
Returns a dict mapping macro name → int value.
Only plain integer literals are handled (no expressions).
"""
text = Path(header_path).read_text()
result = {}
for m in re.finditer(r"#define\s+(\w+)\s+(\d+)", text):
result[m.group(1)] = int(m.group(2))
return result
# ---------------------------------------------------------------------------
# resolve paths
# ---------------------------------------------------------------------------
def resolve_paths() -> tuple[str, str]:
dylib = os.environ.get("AD_DYLIB_PATH") or (sys.argv[1] if len(sys.argv) > 1 else "")
header = os.environ.get("AD_HEADER_PATH") or (sys.argv[2] if len(sys.argv) > 2 else "")
if not dylib:
fail("dylib path required: pass as argv[1] or set AD_DYLIB_PATH")
if not header:
fail("header path required: pass as argv[2] or set AD_HEADER_PATH")
if not Path(dylib).exists():
fail(f"dylib not found: {dylib}")
if not Path(header).exists():
fail(f"header not found: {header}")
return dylib, header
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> None:
dylib_path, header_path = resolve_paths()
print(f"\nLoading dylib: {dylib_path}")
try:
lib = ctypes.CDLL(dylib_path)
except OSError as exc:
fail(f"failed to load dylib: {exc}")
constants = parse_header_constants(header_path)
# ------------------------------------------------------------------
# Bind all symbols upfront with explicit restype + argtypes.
# A missing or wrong-arity symbol surfaces an AttributeError here,
# before any assertion runs.
# ------------------------------------------------------------------
ad_abi_version = bind(lib, "ad_abi_version", c_uint, [])
# Size getters — restype c_size_t, no args
ad_action_size = bind(lib, "ad_action_size", c_size_t, [])
ad_action_result_size = bind(lib, "ad_action_result_size", c_size_t, [])
ad_action_step_size = bind(lib, "ad_action_step_size", c_size_t, [])
ad_drag_params_size = bind(lib, "ad_drag_params_size", c_size_t, [])
ad_element_state_size = bind(lib, "ad_element_state_size", c_size_t, [])
ad_ref_entry_size = bind(lib, "ad_ref_entry_size", c_size_t, [])
ad_wait_args_size = bind(lib, "ad_wait_args_size", c_size_t, [])
# String management
ad_version = bind(lib, "ad_version", c_int, [POINTER(c_char_p)])
ad_free_string = bind(lib, "ad_free_string", None, [c_char_p])
# Adapter lifecycle
ad_adapter_create = bind(lib, "ad_adapter_create", c_void_p, [])
ad_adapter_destroy = bind(lib, "ad_adapter_destroy", None, [c_void_p])
ad_get_clipboard = bind(lib, "ad_get_clipboard", c_int, [c_void_p, POINTER(c_char_p)])
ad_last_error_message = bind(lib, "ad_last_error_message", c_char_p, [])
# Snapshot (adapter leg)
ad_snapshot = bind(
lib, "ad_snapshot", c_int,
[c_void_p, c_char_p, c_int, c_uint8, c_bool, c_bool, POINTER(c_char_p)],
)
print("\nLeg 1 — ABI version handshake")
expected_major = constants.get("AD_ABI_VERSION_MAJOR")
if expected_major is None:
fail("AD_ABI_VERSION_MAJOR not found in header")
got_major = ad_abi_version()
if got_major != expected_major:
fail(
f"ad_abi_version() returned {got_major}, "
f"expected {expected_major} (AD_ABI_VERSION_MAJOR from header)"
)
ok(f"ad_abi_version() == AD_ABI_VERSION_MAJOR == {expected_major}")
# ------------------------------------------------------------------
print("\nLeg 2 — struct size getters vs. header macros")
# ------------------------------------------------------------------
# Explicit table: (getter_fn, macro_name) — no name-mangling magic.
size_pairs = [
(ad_action_size, "AD_ACTION_SIZE"),
(ad_action_result_size, "AD_ACTION_RESULT_SIZE"),
(ad_action_step_size, "AD_ACTION_STEP_SIZE"),
(ad_drag_params_size, "AD_DRAG_PARAMS_SIZE"),
(ad_element_state_size, "AD_ELEMENT_STATE_SIZE"),
(ad_ref_entry_size, "AD_REF_ENTRY_SIZE"),
(ad_wait_args_size, "AD_WAIT_ARGS_SIZE"),
]
for getter, macro in size_pairs:
expected = constants.get(macro)
if expected is None:
fail(f"{macro} not found in header")
got = getter()
if got != expected:
fail(f"{macro}: dylib getter returned {got}, header says {expected} (struct layout drift)")
ok(f"{macro}: {got} bytes")
# ------------------------------------------------------------------
print("\nLeg 3 — ad_version returns parseable JSON with data.version")
# ------------------------------------------------------------------
out = c_char_p()
rc = ad_version(byref(out))
if rc != 0:
fail(f"ad_version() returned {rc}, expected 0 (AD_RESULT_OK)")
if out.value is None:
fail("ad_version() returned OK but *out is null")
raw = out.value # bytes — read before freeing
ad_free_string(out)
try:
envelope = json.loads(raw)
except json.JSONDecodeError as exc:
fail(f"ad_version() output is not valid JSON: {exc}\nraw: {raw!r}")
if not envelope.get("ok"):
fail(f"ad_version() envelope has ok!=true: {envelope}")
data = envelope.get("data")
if not isinstance(data, dict) or "version" not in data:
fail(f"ad_version() envelope missing data.version: {envelope}")
ok(f"ad_version() -> data.version = {data['version']!r}")
# ------------------------------------------------------------------
print("\nLeg 4 — adapter leg: create → snapshot → assert PLATFORM_NOT_SUPPORTED → destroy")
# ------------------------------------------------------------------
expect_stub = os.environ.get("AD_EXPECT_STUB") == "1"
adapter = ad_adapter_create()
if not adapter:
fail("ad_adapter_create() returned null")
ok("ad_adapter_create() non-null")
snap_out = c_char_p()
rc = ad_snapshot(
adapter, # adapter pointer
None, # app (null = focused window)
0, # surface = Window
10, # max_depth
False, # interactive_only
False, # compact
byref(snap_out),
)
# Under the stub adapter, a command-level error writes the error
# envelope to *out rather than nulling it. Parse regardless of rc.
if snap_out.value is not None:
raw_snap = snap_out.value
ad_free_string(snap_out)
try:
snap_env = json.loads(raw_snap)
except json.JSONDecodeError as exc:
ad_adapter_destroy(adapter)
fail(f"ad_snapshot() output is not valid JSON: {exc}\nraw: {raw_snap!r}")
if snap_env.get("ok") is True:
if expect_stub:
ad_adapter_destroy(adapter)
fail(
"ad_snapshot() returned ok:true but AD_EXPECT_STUB=1 — "
"the dylib appears to be built without --features stub-adapter; "
"a stub build cannot successfully complete a real snapshot"
)
# Real adapter succeeded (e.g. local run with AX grant + real adapter).
ok("ad_snapshot() -> ok:true (real adapter path)")
else:
# Expected stub-adapter path: ok:false + PLATFORM_NOT_SUPPORTED.
err = snap_env.get("error", {})
code = err.get("code", "")
if expect_stub and code != "PLATFORM_NOT_SUPPORTED":
ad_adapter_destroy(adapter)
fail(
f"ad_snapshot() envelope error.code = {code!r}, "
"expected 'PLATFORM_NOT_SUPPORTED' (stub adapter)"
)
ok(f"ad_snapshot() -> ok:false, error.code = {code!r}")
else:
# *out null means an argument/infrastructure error — rc is the code.
ad_adapter_destroy(adapter)
fail(
f"ad_snapshot() set *out=null (rc={rc}); "
"expected the error envelope to be written to *out"
)
ad_adapter_destroy(adapter)
ok("ad_adapter_destroy() — no crash, no leak")
if os.environ.get("AD_EXPECT_MACOS_HELPER") == "1":
print("\nLeg 5 — non-colocated Python host discovers the dylib-adjacent macOS helper")
adapter = ad_adapter_create()
if not adapter:
fail("ad_adapter_create() returned null for helper discovery leg")
clipboard = c_char_p()
rc = ad_get_clipboard(adapter, byref(clipboard))
if rc != 0:
message = ad_last_error_message()
helper_runtime_errors = {
b"System clipboard is unavailable",
b"System clipboard read access is denied",
}
if message in helper_runtime_errors:
ad_adapter_destroy(adapter)
ok(
"ctypes host reached the authenticated packaged helper; "
f"pasteboard access was unavailable in this host ({message!r})"
)
print("\nAll legs passed.")
return
ad_adapter_destroy(adapter)
fail(f"dylib-adjacent helper discovery failed rc={rc}: {message!r}")
if clipboard.value is not None:
ad_free_string(clipboard)
ad_adapter_destroy(adapter)
ok("ctypes host read clipboard through the packaged helper")
print("\nAll legs passed.")
if __name__ == "__main__":
main()