Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/docs/reference/cua-driver/mcp-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ from_zoom: set true after a zoom call to auto-translate zoom-image pixel coordin
- `button` (string, optional): Mouse button. Default: "left" — omit for legacy left-click behaviour. Pixel path uses the matching CGEvent primitive; AX path maps "right" to AXShowMenu and falls back to a pixel middle-click at the element's center for "middle".
- `count` (integer, optional): Click count (pixel path only). Default 1.
- `debug_image_out` (string, optional): Optional file path. When set on a pixel-addressed click, captures a fresh screenshot, draws a red crosshair at (x, y), and writes the PNG. Use to verify coordinate spaces. Requires window_id; incompatible with from_zoom.
- `delivery_mode` (string, optional): Best-effort-background ladder rung for a PIXEL click (default "background"). "background": post the CGEvent to the pid without fronting. "foreground": briefly front the window, click, restore the prior frontmost — the explicit last resort for surfaces that drop background synthetic clicks. Requires window_id. A click is never driver-verifiable (no read-back), so both report verified:false — confirm the effect via screenshot. Use the agent loop: background AX (element_index) → screenshot → background pixel (x/y) → screenshot → delivery_mode:"foreground".
- `delivery_mode` (string, optional): Best-effort-background ladder rung (default "background"). "background": perform the AX action or post the CGEvent without fronting. "foreground": briefly front the window, act, let transient UI settle, then restore the prior frontmost app. Requires window_id. A click is never driver-verifiable (no read-back), so both report verified:false — confirm the effect via screenshot. Use the agent loop: background AX (element_index) → screenshot → background pixel (x/y) → screenshot → delivery_mode:"foreground".
- `element_index` (integer, optional): Element index from last get_window_state. REQUIRES `pid` and `window_id` to be passed alongside it — element_index alone (no pid) fails fast with "Missing required integer field: pid"; it is not a silent no-op.
- `element_token` (string, optional): Opaque per-snapshot element handle from `structuredContent.elements[].element_token` of the last get_window_state. Takes precedence over element_index when both supplied. Returns an explicit "stale" error if the snapshot has been superseded — re-snapshot in that case.
- `from_zoom` (boolean, optional): When true, x and y are in the last zoom image for this pid; driver translates back to full-window coordinates.
Expand Down
21 changes: 17 additions & 4 deletions libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ impl ForegroundSentinel {
}
}

/// Re-establish the observation boundary after video capture starts.
/// Capture backends may briefly perturb foreground state while they attach;
/// those setup events are not part of the action under test.
pub fn prepare_background_observation(
&self,
driver: &mut impl Driver,
target: TargetWindow,
) -> Result<(), String> {
activate_native_foreground(driver, self.target);
wait_for_native_focus_stable(self.target);
std::thread::sleep(Duration::from_millis(100));
fs::write(&self.journal_path, "")
.map_err(|error| format!("reset foreground sentinel journal: {error}"))?;
self.assert_background_posture(target)
}

/// Run one background action while checking the native desktop and the
/// sentinel journal. The returned oracle list is suitable for a typed E2E
/// result; any unsupported observation or side effect is an error.
Expand Down Expand Up @@ -261,7 +277,6 @@ fn is_wayland_session() -> bool {
.is_ok_and(|session| session.eq_ignore_ascii_case("wayland"))
}

#[cfg(any(target_os = "windows", target_os = "linux"))]
fn activate_native_foreground(driver: &mut impl Driver, target: TargetWindow) {
let response = driver.call(
"bring_to_front",
Expand Down Expand Up @@ -331,9 +346,6 @@ fn physically_focus_windows_sentinel(target: TargetWindow) {
}
}

#[cfg(not(any(target_os = "windows", target_os = "linux")))]
fn activate_native_foreground(_driver: &mut impl Driver, _target: TargetWindow) {}

#[cfg(any(target_os = "windows", target_os = "linux"))]
fn wait_for_native_focus_stable(target: TargetWindow) {
use crate::observer::{ObserverBackend, TargetZ};
Expand Down Expand Up @@ -373,6 +385,7 @@ pub fn run_with_background_oracles<D: Driver + BehaviorRecording, R>(
let sentinel = ForegroundSentinel::launch(driver);
sentinel.assert_background_posture(target)?;
driver.start_behavior_recording();
sentinel.prepare_background_observation(driver, target)?;
sentinel.observe_background(target, || action(driver))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,15 @@ fn background_screenshot_preserves_desktop() {
})
.expect("establish capture background posture before recording");
driver.start_behavior_recording();
sentinel
.prepare_background_observation(
&mut driver,
TargetWindow {
pid,
native_id: wid,
},
)
.expect("re-establish capture background posture after recording setup");
let (response, mut passed) = sentinel
.observe_background(
TargetWindow {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ where
.expect("establish background posture before recording");
}
fixture.driver.start_behavior_recording();
if let Some(sentinel) = &sentinel {
sentinel
.prepare_background_observation(
&mut fixture.driver,
TargetWindow {
pid: fixture.pid,
native_id: fixture.wid,
},
)
.expect("re-establish background posture after recording setup");
}
let mut observation = if delivery == Delivery::Background {
let sentinel = sentinel.as_ref().expect("background sentinel");
let (mut observation, passed) = sentinel
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! macOS **desktop-scope** (vision/foreground) modality, exercised through the
//! SAME cua-driver interface as the Windows `modality_desktop_scope_test`:
//! `set_config capture_scope=desktop` + a window-less screen-absolute `click`
//! SAME cua-driver interface as the Windows `desktop_scope_windows_test`:
//! a window-less screen-absolute `click` with `scope=desktop`
//! (no `pid`, no `window_id`, no `list_windows`). The macOS actuator resolves
//! the frontmost on-screen window under the point (the `WindowFromPoint` peer,
//! via `CGWindowList`) and clicks it through the proven window-local pixel path,
Expand All @@ -14,24 +14,21 @@
//! asserts the `window`-scope gate rejects a window-less click
//! (`desktop_scope_disabled`), matching the Windows contract.
//!
//! `set_config` is made session-scoped (a `session` arg → `_session_id`), so it
//! is in-memory only and never writes the developer's `~/.cua-driver/config.json`.
//!
//! #[ignore] (needs a real desktop session + TCC Accessibility + the AppKit
//! harness). Run:
//! cargo test -p cua-driver --test modality_desktop_scope_macos_test -- --ignored --nocapture --test-threads=1
//! cargo test -p cua-driver --test desktop_scope_macos_test -- --ignored --nocapture --test-threads=1

#![cfg(target_os = "macos")]

use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use cua_driver_testkit::e2e::{
execute_case, recording_evidence, CaseSpec, Delivery, DriverRoute, Evidence, Observation,
OracleKind, Scope, Targeting,
};
use cua_driver_testkit::{harness_app, Driver, McpDriver};

/// Session id so `set_config capture_scope=desktop` is session-scoped (no disk
/// write) and the `click` resolves the same scope override.
const SESSION: &str = "vf-desktop";

fn harness_exe() -> std::path::PathBuf {
std::env::var("HARNESS_APPKIT_APP")
.map(std::path::PathBuf::from)
Expand All @@ -46,22 +43,36 @@ fn harness_exe() -> std::path::PathBuf {
fn launch(driver: &mut McpDriver) -> Option<(u32, u64)> {
let exe = harness_exe();
if !exe.exists() {
if std::env::var_os("CUA_TEST_REQUIRE_FIXTURES").is_some() {
panic!("required AppKit harness is missing at {exe:?}");
}
eprintln!("[desktop-mac] AppKit harness not built ({exe:?}) — run tests/fixtures/build/macos.sh; skipping");
return None;
}
driver
.reaper()
.spawn(
Command::new(&exe)
.stdout(Stdio::null())
.stderr(Stdio::null()),
)
.ok()?;
let child = match cua_driver_testkit::spawn_in_job(
Command::new(&exe)
.stdout(Stdio::null())
.stderr(Stdio::null()),
) {
Ok(child) => child,
Err(error) => {
if std::env::var_os("CUA_TEST_REQUIRE_FIXTURES").is_some() {
panic!("failed to launch required AppKit harness {exe:?}: {error}");
}
eprintln!("[desktop-mac] AppKit harness launch failed: {error}; skipping");
return None;
}
};
let launched_pid = child.id();
driver.reaper().push(child);
let deadline = Instant::now() + Duration::from_secs(14);
while Instant::now() < deadline {
let r = driver.call("list_windows", serde_json::json!({}));
if let Some(wins) = r.structured()["windows"].as_array() {
for w in wins {
if w["pid"].as_u64() != Some(launched_pid as u64) {
continue;
}
if w["title"]
.as_str()
.unwrap_or("")
Expand All @@ -70,14 +81,16 @@ fn launch(driver: &mut McpDriver) -> Option<(u32, u64)> {
let pid = w["pid"].as_u64().unwrap_or(0) as u32;
let wid = w["window_id"].as_u64().unwrap_or(0);
if pid != 0 && wid != 0 {
driver.reaper().track_pid(pid);
return Some((pid, wid));
}
}
}
}
std::thread::sleep(Duration::from_millis(400));
}
if std::env::var_os("CUA_TEST_REQUIRE_FIXTURES").is_some() {
panic!("required AppKit harness window never appeared");
}
eprintln!(
"[desktop-mac] harness window never appeared — graphical session available? skipping"
);
Expand Down Expand Up @@ -156,67 +169,68 @@ fn activate_pid(pid: u32) {
#[test]
#[ignore]
fn desktop_scope_windowless_click_lands_on_control() {
let Some(mut driver) = McpDriver::spawn_macos_daemon_proxy() else {
return;
};
let Some((pid, wid)) = launch(&mut driver) else {
return;
};
let cell_id = "macos-appkit-desktop-left-click-px-foreground";
let case = CaseSpec::delivered(
cell_id,
"appkit",
"appkit",
"left_click",
Targeting::Px,
Delivery::Foreground,
Scope::Desktop,
DriverRoute::MacosCgEventHid,
vec![OracleKind::FixtureState],
);
execute_case(case, |evidence| {
let mut driver = McpDriver::spawn_macos_daemon_proxy_named(cell_id)
.expect("start installed macOS daemon proxy");
*evidence = recording_evidence(driver.recording_dir());
let (pid, wid) = launch(&mut driver).expect("required AppKit harness did not launch");

// Settle for the AppKit AX tree to register the button + its frame.
let mut snap = ax_snapshot(&mut driver, pid, wid);
let mut center = increment_center(&snap);
let deadline = Instant::now() + Duration::from_secs(8);
while center.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(400));
snap = ax_snapshot(&mut driver, pid, wid);
center = increment_center(&snap);
}
let Some((cx, cy)) = center else {
eprintln!("[desktop-mac] increment button frame not found (TCC Accessibility missing?) — skipping");
return;
};
let pre = counter(&snap).unwrap_or(0);
println!("[desktop-mac] increment button screen-center=({cx},{cy}) pre-counter={pre}");
// Settle for the AppKit AX tree to register the button + its frame.
let mut snap = ax_snapshot(&mut driver, pid, wid);
let mut center = increment_center(&snap);
let deadline = Instant::now() + Duration::from_secs(8);
while center.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(400));
snap = ax_snapshot(&mut driver, pid, wid);
center = increment_center(&snap);
}
let Some((cx, cy)) = center else {
panic!("increment button frame not found in required AppKit AX tree");
};
let pre = counter(&snap).unwrap_or(0);
println!("[desktop-mac] increment button screen-center=({cx},{cy}) pre-counter={pre}");

// Desktop scope clicks the frontmost window at the point — put the harness there.
activate_pid(pid);
// Desktop scope clicks the frontmost window at the point — put the harness there.
activate_pid(pid);
driver.start_behavior_recording();

// Window-less screen-absolute click — no pid, no window_id; scope per-call.
let clicked = driver.call(
"click",
serde_json::json!({ "x": cx, "y": cy, "scope": "desktop", "session": SESSION }),
);
assert!(
!clicked.is_error(),
"desktop-scope click errored: {}",
clicked.text()
);
assert!(
clicked.text().to_lowercase().contains("desktop scope"),
"click not reported as desktop-scope: {}",
clicked.text()
);
println!("[desktop-mac] {}", clicked.text());
// Window-less screen-absolute click — no pid, no window_id; scope per-call.
let clicked = driver.call(
"click",
serde_json::json!({ "x": cx, "y": cy, "scope": "desktop" }),
);
assert!(
!clicked.is_error(),
"desktop-scope click errored: {}",
clicked.text()
);
assert!(
clicked.text().to_lowercase().contains("desktop scope"),
"click not reported as desktop-scope: {}",
clicked.text()
);
println!("[desktop-mac] {}", clicked.text());

std::thread::sleep(Duration::from_millis(600));
let post = counter(&ax_snapshot(&mut driver, pid, wid)).unwrap_or(pre);
if post > pre {
println!("✅ desktop_scope_windowless_click_lands_on_control: counter {pre} → {post}");
return;
}
// The desktop click lands on whatever window is *visually frontmost* at the
// point — that is the contract. On a busy desktop another window can cover
// the harness (and `activate` may not beat a floating panel), so a
// non-advance here means the harness was not frontmost at the point, NOT a
// driver fault. The click is confirmed to have resolved a real window (see
// its result text above). Skip rather than false-fail; a clean session
// asserts the landing, as the Linux peer does end-to-end.
eprintln!(
"[desktop-mac] counter did not advance ({pre}→{post}) — the harness was not the \
frontmost window at ({cx},{cy}) on this desktop (another window covering it). \
Skipping the landing assertion; run on a clean GUI session to assert it."
);
std::thread::sleep(Duration::from_millis(600));
let post = counter(&ax_snapshot(&mut driver, pid, wid)).unwrap_or(pre);
assert!(
post > pre,
"desktop click did not advance AppKit counter at ({cx},{cy}): {pre} -> {post}"
);
Observation::delivered_with_fixture_state(Vec::new())
});
}

/// Negative gate: a window-less screen-absolute click while `capture_scope=window`
Expand All @@ -225,19 +239,33 @@ fn desktop_scope_windowless_click_lands_on_control() {
#[test]
#[ignore]
fn window_scope_rejects_windowless_click() {
let Some(mut driver) = McpDriver::spawn_macos_daemon_proxy() else {
return;
};
// Default scope is "window" — a window-less click must be rejected.
let r = driver.call(
"click",
serde_json::json!({ "x": 100, "y": 100, "scope": "window", "session": SESSION }),
);
let txt = r.text().to_lowercase();
assert!(
r.is_error() || txt.contains("desktop scope") || txt.contains("desktop_scope_disabled"),
"window-scope window-less click was NOT rejected: {}",
r.text()
let cell_id = "macos-window-scope-gate-px-not-applicable";
let case = CaseSpec::delivered(
cell_id,
"desktop",
"quartz",
"window_scope_gate",
Targeting::Px,
Delivery::NotApplicable,
Scope::Window,
DriverRoute::Composite,
vec![OracleKind::Protocol],
);
println!("✅ window_scope_rejects_windowless_click: window-less click correctly gated");
execute_case(case, |evidence| {
let mut driver = McpDriver::spawn_macos_daemon_proxy_named(cell_id)
.expect("start installed macOS daemon proxy");
*evidence = recording_evidence(driver.recording_dir());
// Default scope is "window" — a window-less click must be rejected.
driver.start_behavior_recording();
let r = driver.call(
"click",
serde_json::json!({ "x": 100, "y": 100, "scope": "window" }),
);
assert!(
r.is_error() && r.structured()["code"].as_str() == Some("desktop_scope_disabled"),
"window-scope window-less click was NOT rejected: {}",
r.text()
);
Observation::delivered(vec![OracleKind::Protocol], Evidence::default())
});
}
Loading
Loading