Skip to main content

macos_agent/backend/
mod.rs

1pub mod applescript;
2pub mod cliclick;
3pub mod hammerspoon;
4pub mod input_source;
5pub mod process;
6
7use crate::backend::hammerspoon::HammerspoonAxBackend;
8use crate::backend::process::ProcessRunner;
9use crate::error::CliError;
10use crate::model::{
11    AxActionPerformRequest, AxActionPerformResult, AxAttrGetRequest, AxAttrGetResult,
12    AxAttrSetRequest, AxAttrSetResult, AxClickRequest, AxClickResult, AxListRequest, AxListResult,
13    AxSessionListResult, AxSessionStartRequest, AxSessionStartResult, AxSessionStopRequest,
14    AxSessionStopResult, AxTypeRequest, AxTypeResult, AxWatchPollRequest, AxWatchPollResult,
15    AxWatchStartRequest, AxWatchStartResult, AxWatchStopRequest, AxWatchStopResult,
16};
17use crate::test_mode;
18
19const AX_EXTENDED_CAPABILITY_HINT: &str =
20    "AX attr/action/session/watch commands require Hammerspoon backend (`hs`).";
21const AX_EXTENDED_CAPABILITY_ACTION_HINT: &str = "Use `AGENTS_MACOS_AGENT_AX_BACKEND=hammerspoon|auto` and run `macos-agent preflight --include-probes` to verify readiness.";
22
23pub trait AxBackendAdapter {
24    fn list(
25        &self,
26        runner: &dyn ProcessRunner,
27        request: &AxListRequest,
28        timeout_ms: u64,
29    ) -> Result<AxListResult, CliError>;
30
31    fn click(
32        &self,
33        runner: &dyn ProcessRunner,
34        request: &AxClickRequest,
35        timeout_ms: u64,
36    ) -> Result<AxClickResult, CliError>;
37
38    fn type_text(
39        &self,
40        runner: &dyn ProcessRunner,
41        request: &AxTypeRequest,
42        timeout_ms: u64,
43    ) -> Result<AxTypeResult, CliError>;
44
45    fn attr_get(
46        &self,
47        _runner: &dyn ProcessRunner,
48        _request: &AxAttrGetRequest,
49        _timeout_ms: u64,
50    ) -> Result<AxAttrGetResult, CliError> {
51        Err(
52            CliError::runtime("AX attribute get is not supported by this backend")
53                .with_operation("ax.attr.get")
54                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
55                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
56        )
57    }
58
59    fn attr_set(
60        &self,
61        _runner: &dyn ProcessRunner,
62        _request: &AxAttrSetRequest,
63        _timeout_ms: u64,
64    ) -> Result<AxAttrSetResult, CliError> {
65        Err(
66            CliError::runtime("AX attribute set is not supported by this backend")
67                .with_operation("ax.attr.set")
68                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
69                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
70        )
71    }
72
73    fn action_perform(
74        &self,
75        _runner: &dyn ProcessRunner,
76        _request: &AxActionPerformRequest,
77        _timeout_ms: u64,
78    ) -> Result<AxActionPerformResult, CliError> {
79        Err(
80            CliError::runtime("AX action perform is not supported by this backend")
81                .with_operation("ax.action.perform")
82                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
83                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
84        )
85    }
86
87    fn session_start(
88        &self,
89        _runner: &dyn ProcessRunner,
90        _request: &AxSessionStartRequest,
91        _timeout_ms: u64,
92    ) -> Result<AxSessionStartResult, CliError> {
93        Err(
94            CliError::runtime("AX session start is not supported by this backend")
95                .with_operation("ax.session.start")
96                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
97                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
98        )
99    }
100
101    fn session_list(
102        &self,
103        _runner: &dyn ProcessRunner,
104        _timeout_ms: u64,
105    ) -> Result<AxSessionListResult, CliError> {
106        Err(
107            CliError::runtime("AX session list is not supported by this backend")
108                .with_operation("ax.session.list")
109                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
110                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
111        )
112    }
113
114    fn session_stop(
115        &self,
116        _runner: &dyn ProcessRunner,
117        _request: &AxSessionStopRequest,
118        _timeout_ms: u64,
119    ) -> Result<AxSessionStopResult, CliError> {
120        Err(
121            CliError::runtime("AX session stop is not supported by this backend")
122                .with_operation("ax.session.stop")
123                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
124                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
125        )
126    }
127
128    fn watch_start(
129        &self,
130        _runner: &dyn ProcessRunner,
131        _request: &AxWatchStartRequest,
132        _timeout_ms: u64,
133    ) -> Result<AxWatchStartResult, CliError> {
134        Err(
135            CliError::runtime("AX watch start is not supported by this backend")
136                .with_operation("ax.watch.start")
137                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
138                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
139        )
140    }
141
142    fn watch_poll(
143        &self,
144        _runner: &dyn ProcessRunner,
145        _request: &AxWatchPollRequest,
146        _timeout_ms: u64,
147    ) -> Result<AxWatchPollResult, CliError> {
148        Err(
149            CliError::runtime("AX watch poll is not supported by this backend")
150                .with_operation("ax.watch.poll")
151                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
152                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
153        )
154    }
155
156    fn watch_stop(
157        &self,
158        _runner: &dyn ProcessRunner,
159        _request: &AxWatchStopRequest,
160        _timeout_ms: u64,
161    ) -> Result<AxWatchStopResult, CliError> {
162        Err(
163            CliError::runtime("AX watch stop is not supported by this backend")
164                .with_operation("ax.watch.stop")
165                .with_hint(AX_EXTENDED_CAPABILITY_HINT)
166                .with_hint(AX_EXTENDED_CAPABILITY_ACTION_HINT),
167        )
168    }
169}
170
171#[derive(Debug, Default, Clone, Copy)]
172pub struct AppleScriptAxBackend;
173
174impl AxBackendAdapter for AppleScriptAxBackend {
175    fn list(
176        &self,
177        runner: &dyn ProcessRunner,
178        request: &AxListRequest,
179        timeout_ms: u64,
180    ) -> Result<AxListResult, CliError> {
181        applescript::ax_list(runner, request, timeout_ms)
182    }
183
184    fn click(
185        &self,
186        runner: &dyn ProcessRunner,
187        request: &AxClickRequest,
188        timeout_ms: u64,
189    ) -> Result<AxClickResult, CliError> {
190        applescript::ax_click(runner, request, timeout_ms)
191    }
192
193    fn type_text(
194        &self,
195        runner: &dyn ProcessRunner,
196        request: &AxTypeRequest,
197        timeout_ms: u64,
198    ) -> Result<AxTypeResult, CliError> {
199        applescript::ax_type(runner, request, timeout_ms)
200    }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum AxBackendPreference {
205    Auto,
206    Hammerspoon,
207    AppleScript,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct AxBackendCapabilityCheck {
212    pub message: String,
213    pub hint: Option<String>,
214}
215
216impl AxBackendPreference {
217    pub fn as_str(self) -> &'static str {
218        match self {
219            Self::Auto => "auto",
220            Self::Hammerspoon => "hammerspoon",
221            Self::AppleScript => "applescript",
222        }
223    }
224
225    pub fn resolve() -> Self {
226        if let Ok(raw) = std::env::var("AGENTS_MACOS_AGENT_AX_BACKEND") {
227            match raw.trim().to_ascii_lowercase().as_str() {
228                "hammerspoon" | "hs" => return Self::Hammerspoon,
229                "applescript" | "jxa" => return Self::AppleScript,
230                "auto" => return Self::Auto,
231                _ => {}
232            }
233        }
234
235        if test_mode::enabled() {
236            // Tests rely on deterministic osascript stubs.
237            Self::AppleScript
238        } else {
239            Self::Auto
240        }
241    }
242
243    fn capability_message(self) -> &'static str {
244        match self {
245            Self::Auto => {
246                "AX backend preference=auto; list/click/type use Hammerspoon first and may fallback to AppleScript (JXA). attr/action/session/watch remain Hammerspoon-only."
247            }
248            Self::Hammerspoon => {
249                "AX backend preference=hammerspoon; list/click/type and attr/action/session/watch all use Hammerspoon."
250            }
251            Self::AppleScript => {
252                "AX backend preference=applescript; list/click/type use AppleScript (JXA), while attr/action/session/watch still require Hammerspoon."
253            }
254        }
255    }
256}
257
258pub fn preflight_capability_check() -> AxBackendCapabilityCheck {
259    let preference = AxBackendPreference::resolve();
260    let hint = if preference == AxBackendPreference::Hammerspoon {
261        None
262    } else {
263        Some(AX_EXTENDED_CAPABILITY_ACTION_HINT.to_string())
264    };
265    AxBackendCapabilityCheck {
266        message: preference.capability_message().to_string(),
267        hint,
268    }
269}
270
271#[derive(Debug, Clone, Copy)]
272pub struct AutoAxBackend {
273    preference: AxBackendPreference,
274}
275
276impl Default for AutoAxBackend {
277    fn default() -> Self {
278        Self {
279            preference: AxBackendPreference::resolve(),
280        }
281    }
282}
283
284impl AutoAxBackend {
285    fn fallback_with_hint(primary_error: CliError, fallback_error: CliError) -> CliError {
286        fallback_error.with_hint(format!(
287            "Hammerspoon backend failed first: {}",
288            primary_error.message()
289        ))
290    }
291}
292
293impl AxBackendAdapter for AutoAxBackend {
294    fn list(
295        &self,
296        runner: &dyn ProcessRunner,
297        request: &AxListRequest,
298        timeout_ms: u64,
299    ) -> Result<AxListResult, CliError> {
300        match self.preference {
301            AxBackendPreference::Hammerspoon => {
302                HammerspoonAxBackend.list(runner, request, timeout_ms)
303            }
304            AxBackendPreference::AppleScript => {
305                AppleScriptAxBackend.list(runner, request, timeout_ms)
306            }
307            AxBackendPreference::Auto => match HammerspoonAxBackend
308                .list(runner, request, timeout_ms)
309            {
310                Ok(result) => Ok(result),
311                Err(primary_error) if hammerspoon::is_backend_unavailable_error(&primary_error) => {
312                    AppleScriptAxBackend
313                        .list(runner, request, timeout_ms)
314                        .map_err(|fallback_error| {
315                            Self::fallback_with_hint(primary_error, fallback_error)
316                        })
317                }
318                Err(error) => Err(error),
319            },
320        }
321    }
322
323    fn click(
324        &self,
325        runner: &dyn ProcessRunner,
326        request: &AxClickRequest,
327        timeout_ms: u64,
328    ) -> Result<AxClickResult, CliError> {
329        match self.preference {
330            AxBackendPreference::Hammerspoon => {
331                HammerspoonAxBackend.click(runner, request, timeout_ms)
332            }
333            AxBackendPreference::AppleScript => {
334                AppleScriptAxBackend.click(runner, request, timeout_ms)
335            }
336            AxBackendPreference::Auto => match HammerspoonAxBackend
337                .click(runner, request, timeout_ms)
338            {
339                Ok(result) => Ok(result),
340                Err(primary_error) if hammerspoon::is_backend_unavailable_error(&primary_error) => {
341                    AppleScriptAxBackend
342                        .click(runner, request, timeout_ms)
343                        .map_err(|fallback_error| {
344                            Self::fallback_with_hint(primary_error, fallback_error)
345                        })
346                }
347                Err(error) => Err(error),
348            },
349        }
350    }
351
352    fn type_text(
353        &self,
354        runner: &dyn ProcessRunner,
355        request: &AxTypeRequest,
356        timeout_ms: u64,
357    ) -> Result<AxTypeResult, CliError> {
358        match self.preference {
359            AxBackendPreference::Hammerspoon => {
360                HammerspoonAxBackend.type_text(runner, request, timeout_ms)
361            }
362            AxBackendPreference::AppleScript => {
363                AppleScriptAxBackend.type_text(runner, request, timeout_ms)
364            }
365            AxBackendPreference::Auto => {
366                match HammerspoonAxBackend.type_text(runner, request, timeout_ms) {
367                    Ok(result) => Ok(result),
368                    Err(primary_error)
369                        if hammerspoon::is_backend_unavailable_error(&primary_error) =>
370                    {
371                        AppleScriptAxBackend
372                            .type_text(runner, request, timeout_ms)
373                            .map_err(|fallback_error| {
374                                Self::fallback_with_hint(primary_error, fallback_error)
375                            })
376                    }
377                    Err(error) => Err(error),
378                }
379            }
380        }
381    }
382
383    fn attr_get(
384        &self,
385        runner: &dyn ProcessRunner,
386        request: &AxAttrGetRequest,
387        timeout_ms: u64,
388    ) -> Result<AxAttrGetResult, CliError> {
389        HammerspoonAxBackend.attr_get(runner, request, timeout_ms)
390    }
391
392    fn attr_set(
393        &self,
394        runner: &dyn ProcessRunner,
395        request: &AxAttrSetRequest,
396        timeout_ms: u64,
397    ) -> Result<AxAttrSetResult, CliError> {
398        HammerspoonAxBackend.attr_set(runner, request, timeout_ms)
399    }
400
401    fn action_perform(
402        &self,
403        runner: &dyn ProcessRunner,
404        request: &AxActionPerformRequest,
405        timeout_ms: u64,
406    ) -> Result<AxActionPerformResult, CliError> {
407        HammerspoonAxBackend.action_perform(runner, request, timeout_ms)
408    }
409
410    fn session_start(
411        &self,
412        runner: &dyn ProcessRunner,
413        request: &AxSessionStartRequest,
414        timeout_ms: u64,
415    ) -> Result<AxSessionStartResult, CliError> {
416        HammerspoonAxBackend.session_start(runner, request, timeout_ms)
417    }
418
419    fn session_list(
420        &self,
421        runner: &dyn ProcessRunner,
422        timeout_ms: u64,
423    ) -> Result<AxSessionListResult, CliError> {
424        HammerspoonAxBackend.session_list(runner, timeout_ms)
425    }
426
427    fn session_stop(
428        &self,
429        runner: &dyn ProcessRunner,
430        request: &AxSessionStopRequest,
431        timeout_ms: u64,
432    ) -> Result<AxSessionStopResult, CliError> {
433        HammerspoonAxBackend.session_stop(runner, request, timeout_ms)
434    }
435
436    fn watch_start(
437        &self,
438        runner: &dyn ProcessRunner,
439        request: &AxWatchStartRequest,
440        timeout_ms: u64,
441    ) -> Result<AxWatchStartResult, CliError> {
442        HammerspoonAxBackend.watch_start(runner, request, timeout_ms)
443    }
444
445    fn watch_poll(
446        &self,
447        runner: &dyn ProcessRunner,
448        request: &AxWatchPollRequest,
449        timeout_ms: u64,
450    ) -> Result<AxWatchPollResult, CliError> {
451        HammerspoonAxBackend.watch_poll(runner, request, timeout_ms)
452    }
453
454    fn watch_stop(
455        &self,
456        runner: &dyn ProcessRunner,
457        request: &AxWatchStopRequest,
458        timeout_ms: u64,
459    ) -> Result<AxWatchStopResult, CliError> {
460        HammerspoonAxBackend.watch_stop(runner, request, timeout_ms)
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use nils_test_support::{EnvGuard, GlobalStateLock};
467    use pretty_assertions::assert_eq;
468    use serde_json::json;
469
470    use crate::backend::process::RealProcessRunner;
471    use crate::backend::{
472        AppleScriptAxBackend, AutoAxBackend, AxBackendAdapter, AxBackendPreference,
473    };
474    use crate::model::{
475        AxActionPerformRequest, AxAttrGetRequest, AxAttrSetRequest, AxClickRequest, AxListRequest,
476        AxSelector, AxSessionStartRequest, AxSessionStopRequest, AxTarget, AxTypeRequest,
477        AxWatchPollRequest, AxWatchStartRequest, AxWatchStopRequest,
478    };
479
480    fn node_selector() -> AxSelector {
481        AxSelector {
482            node_id: Some("1.1".to_string()),
483            ..AxSelector::default()
484        }
485    }
486
487    #[test]
488    fn backend_preference_defaults_to_applescript_in_test_mode() {
489        let lock = GlobalStateLock::new();
490        let _test_mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
491        let _backend = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND");
492        assert_eq!(
493            AxBackendPreference::resolve(),
494            AxBackendPreference::AppleScript
495        );
496    }
497
498    #[test]
499    fn backend_preference_env_overrides_test_mode_default() {
500        let lock = GlobalStateLock::new();
501        let _test_mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
502        let _backend = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND", "hammerspoon");
503        assert_eq!(
504            AxBackendPreference::resolve(),
505            AxBackendPreference::Hammerspoon
506        );
507    }
508
509    #[test]
510    fn applescript_backend_reports_unsupported_for_ax_extension_methods() {
511        let runner = RealProcessRunner;
512        let request_target = AxTarget::default();
513        let selector = node_selector();
514
515        let attr_get = AppleScriptAxBackend.attr_get(
516            &runner,
517            &AxAttrGetRequest {
518                target: request_target.clone(),
519                selector: selector.clone(),
520                name: "AXRole".to_string(),
521            },
522            1000,
523        );
524        assert!(attr_get.is_err());
525
526        let attr_set = AppleScriptAxBackend.attr_set(
527            &runner,
528            &AxAttrSetRequest {
529                target: request_target.clone(),
530                selector: selector.clone(),
531                name: "AXValue".to_string(),
532                value: json!("hello"),
533            },
534            1000,
535        );
536        assert!(attr_set.is_err());
537
538        let action = AppleScriptAxBackend.action_perform(
539            &runner,
540            &AxActionPerformRequest {
541                target: request_target,
542                selector,
543                name: "AXPress".to_string(),
544            },
545            1000,
546        );
547        assert!(action.is_err());
548    }
549
550    #[test]
551    fn auto_backend_hammerspoon_preference_routes_list_click_type() {
552        let lock = GlobalStateLock::new();
553        let _test_mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
554        let _backend = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND", "hammerspoon");
555        let _list = EnvGuard::set(
556            &lock,
557            "AGENTS_MACOS_AGENT_AX_LIST_JSON",
558            r#"{"nodes":[{"node_id":"1.1","role":"AXButton","enabled":true,"focused":false,"actions":[],"path":["1","1"]}],"warnings":[]}"#,
559        );
560        let _click = EnvGuard::set(
561            &lock,
562            "AGENTS_MACOS_AGENT_AX_CLICK_JSON",
563            r#"{"node_id":"1.1","matched_count":1,"action":"ax-press","used_coordinate_fallback":false}"#,
564        );
565        let _typ = EnvGuard::set(
566            &lock,
567            "AGENTS_MACOS_AGENT_AX_TYPE_JSON",
568            r#"{"node_id":"1.1","matched_count":1,"applied_via":"ax-set-value","text_length":4,"submitted":false,"used_keyboard_fallback":false}"#,
569        );
570
571        let backend = AutoAxBackend::default();
572        let runner = RealProcessRunner;
573        let list = backend
574            .list(&runner, &AxListRequest::default(), 1000)
575            .expect("list should succeed");
576        assert_eq!(list.nodes.len(), 1);
577
578        let click = backend
579            .click(
580                &runner,
581                &AxClickRequest {
582                    target: AxTarget::default(),
583                    selector: node_selector(),
584                    allow_coordinate_fallback: false,
585                    reselect_before_click: false,
586                    fallback_order: Vec::new(),
587                },
588                1000,
589            )
590            .expect("click should succeed");
591        assert_eq!(click.matched_count, 1);
592
593        let typ = backend
594            .type_text(
595                &runner,
596                &AxTypeRequest {
597                    target: AxTarget::default(),
598                    selector: node_selector(),
599                    text: "test".to_string(),
600                    clear_first: false,
601                    submit: false,
602                    paste: false,
603                    allow_keyboard_fallback: false,
604                },
605                1000,
606            )
607            .expect("type should succeed");
608        assert_eq!(typ.text_length, 4);
609    }
610
611    #[test]
612    fn auto_backend_auto_preference_uses_hammerspoon_first_when_available() {
613        let lock = GlobalStateLock::new();
614        let _test_mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
615        let _backend = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND", "auto");
616        let _list = EnvGuard::set(
617            &lock,
618            "AGENTS_MACOS_AGENT_AX_LIST_JSON",
619            r#"{"nodes":[{"node_id":"9.9","role":"AXButton","enabled":true,"focused":false,"actions":[],"path":["9","9"]}],"warnings":[]}"#,
620        );
621
622        let backend = AutoAxBackend::default();
623        let runner = RealProcessRunner;
624        let list = backend
625            .list(&runner, &AxListRequest::default(), 1000)
626            .expect("list should succeed");
627        assert_eq!(list.nodes[0].node_id, "9.9");
628    }
629
630    #[test]
631    fn auto_backend_ax_extension_methods_route_through_hammerspoon() {
632        let lock = GlobalStateLock::new();
633        let _test_mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
634        let _backend = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND", "applescript");
635
636        let _attr_get = EnvGuard::set(
637            &lock,
638            "AGENTS_MACOS_AGENT_AX_ATTR_GET_JSON",
639            r#"{"node_id":"1.1","matched_count":1,"name":"AXRole","value":"AXButton"}"#,
640        );
641        let _attr_set = EnvGuard::set(
642            &lock,
643            "AGENTS_MACOS_AGENT_AX_ATTR_SET_JSON",
644            r#"{"node_id":"1.1","matched_count":1,"name":"AXValue","applied":true,"value_type":"string"}"#,
645        );
646        let _action = EnvGuard::set(
647            &lock,
648            "AGENTS_MACOS_AGENT_AX_ACTION_PERFORM_JSON",
649            r#"{"node_id":"1.1","matched_count":1,"name":"AXPress","performed":true}"#,
650        );
651        let _session_start = EnvGuard::set(
652            &lock,
653            "AGENTS_MACOS_AGENT_AX_SESSION_START_JSON",
654            r#"{"session_id":"axs-1","app":"Arc","bundle_id":"company.thebrowser.Browser","pid":1001,"created_at_ms":1700000000000,"created":true}"#,
655        );
656        let _session_list = EnvGuard::set(
657            &lock,
658            "AGENTS_MACOS_AGENT_AX_SESSION_LIST_JSON",
659            r#"{"sessions":[{"session_id":"axs-1","app":"Arc","bundle_id":"company.thebrowser.Browser","pid":1001,"created_at_ms":1700000000000}]}"#,
660        );
661        let _session_stop = EnvGuard::set(
662            &lock,
663            "AGENTS_MACOS_AGENT_AX_SESSION_STOP_JSON",
664            r#"{"session_id":"axs-1","removed":true}"#,
665        );
666        let _watch_start = EnvGuard::set(
667            &lock,
668            "AGENTS_MACOS_AGENT_AX_WATCH_START_JSON",
669            r#"{"watch_id":"axw-1","session_id":"axs-1","events":["AXTitleChanged"],"max_buffer":64,"started":true}"#,
670        );
671        let _watch_poll = EnvGuard::set(
672            &lock,
673            "AGENTS_MACOS_AGENT_AX_WATCH_POLL_JSON",
674            r#"{"watch_id":"axw-1","events":[],"dropped":0,"running":true}"#,
675        );
676        let _watch_stop = EnvGuard::set(
677            &lock,
678            "AGENTS_MACOS_AGENT_AX_WATCH_STOP_JSON",
679            r#"{"watch_id":"axw-1","stopped":true,"drained":0}"#,
680        );
681
682        let backend = AutoAxBackend::default();
683        let runner = RealProcessRunner;
684
685        let attr_get = backend
686            .attr_get(
687                &runner,
688                &AxAttrGetRequest {
689                    target: AxTarget::default(),
690                    selector: node_selector(),
691                    name: "AXRole".to_string(),
692                },
693                1000,
694            )
695            .expect("attr get should succeed");
696        assert_eq!(attr_get.name, "AXRole");
697
698        let attr_set = backend
699            .attr_set(
700                &runner,
701                &AxAttrSetRequest {
702                    target: AxTarget::default(),
703                    selector: node_selector(),
704                    name: "AXValue".to_string(),
705                    value: json!("hello"),
706                },
707                1000,
708            )
709            .expect("attr set should succeed");
710        assert!(attr_set.applied);
711
712        let action = backend
713            .action_perform(
714                &runner,
715                &AxActionPerformRequest {
716                    target: AxTarget::default(),
717                    selector: node_selector(),
718                    name: "AXPress".to_string(),
719                },
720                1000,
721            )
722            .expect("action should succeed");
723        assert!(action.performed);
724
725        let start = backend
726            .session_start(
727                &runner,
728                &AxSessionStartRequest {
729                    target: AxTarget::default(),
730                    session_id: Some("axs-1".to_string()),
731                },
732                1000,
733            )
734            .expect("session start should succeed");
735        assert_eq!(start.session.session_id, "axs-1");
736
737        let listed = backend
738            .session_list(&runner, 1000)
739            .expect("session list should succeed");
740        assert_eq!(listed.sessions.len(), 1);
741
742        let stop = backend
743            .session_stop(
744                &runner,
745                &AxSessionStopRequest {
746                    session_id: "axs-1".to_string(),
747                },
748                1000,
749            )
750            .expect("session stop should succeed");
751        assert!(stop.removed);
752
753        let watch_start = backend
754            .watch_start(
755                &runner,
756                &AxWatchStartRequest {
757                    session_id: "axs-1".to_string(),
758                    events: vec!["AXTitleChanged".to_string()],
759                    max_buffer: 64,
760                    watch_id: Some("axw-1".to_string()),
761                },
762                1000,
763            )
764            .expect("watch start should succeed");
765        assert_eq!(watch_start.watch_id, "axw-1");
766
767        let watch_poll = backend
768            .watch_poll(
769                &runner,
770                &AxWatchPollRequest {
771                    watch_id: "axw-1".to_string(),
772                    limit: 10,
773                    drain: true,
774                },
775                1000,
776            )
777            .expect("watch poll should succeed");
778        assert!(watch_poll.running);
779
780        let watch_stop = backend
781            .watch_stop(
782                &runner,
783                &AxWatchStopRequest {
784                    watch_id: "axw-1".to_string(),
785                },
786                1000,
787            )
788            .expect("watch stop should succeed");
789        assert!(watch_stop.stopped);
790    }
791}