diff --git a/crates/glass-mcp/src/tools.rs b/crates/glass-mcp/src/tools.rs index 67a9e32b..fe939ff2 100644 --- a/crates/glass-mcp/src/tools.rs +++ b/crates/glass-mcp/src/tools.rs @@ -497,1771 +497,7 @@ mod input; // filled in Task 5 mod wait; #[cfg(test)] -pub(crate) mod testutil { - //! A scriptable in-memory `Platform` so tool logic can be tested with no X - //! server. Mirrors the one in glass-core's own tests. - use std::collections::VecDeque; - use std::sync::{Arc, Mutex}; - - use glass_core::{ - Accessibility, AppSpec, AxContext, AxNode, AxNodeId, AxRect, AxRole, AxStates, AxTarget, - AxTree, Backend, BaselineStore, Frame, Glass, GlassError, KeyEvent, Platform, - PlatformFactory, PointerEvent, Region, Result, Stream, Truncation, TruncationLimit, - WindowGeometry, WindowId, WindowInfo, WindowOp, - }; - - use super::{OutContent, ToolOutput}; - - #[derive(Default)] - pub struct FakePlatform { - pub geometry: WindowGeometry, - pub frames: VecDeque, - pub pending_logs: Vec<(Stream, String)>, - pub pointer_events: Vec, - pub key_events: Vec, - pub started: bool, - pub events: Arc>>, - pub clipboard: String, - /// Count of `capture_frame` calls — lets a test assert a settle actually captured - /// frames (e.g. `return:"snapshot"` settling before it folds the tree). - pub captures: Arc>, - /// Specs `start_app` was handed, in order — the only observer of what the tool layer - /// built from `glass_start`'s arguments. - pub specs: Arc>>, - } - - impl FakePlatform { - pub fn new(width: u32, height: u32) -> Self { - Self { - geometry: WindowGeometry { - x: 0, - y: 0, - width, - height, - }, - ..Default::default() - } - } - pub fn with_frames(mut self, frames: Vec) -> Self { - self.frames = frames.into(); - self - } - pub fn with_logs(mut self, logs: Vec<(Stream, &str)>) -> Self { - self.pending_logs = logs.into_iter().map(|(s, t)| (s, t.to_string())).collect(); - self - } - pub fn with_event_log(mut self, log: Arc>>) -> Self { - self.events = log; - self - } - pub fn with_capture_log(mut self, log: Arc>) -> Self { - self.captures = log; - self - } - pub fn with_spec_log(mut self, log: Arc>>) -> Self { - self.specs = log; - self - } - } - - /// A 4x4 opaque frame, constant everywhere except pixel (3,3), set to `corner` — - /// a stand-in for a perpetually animating rect (a blinking caret, a clock) in - /// `ignore`-masking tests. Mirrors glass-core's own test helper of the same name. - pub fn frame_4x4_corner(corner: [u8; 4]) -> Frame { - let mut px = vec![0u8; 4 * 4 * 4]; - for i in 0..16 { - px[i * 4 + 3] = 255; // alpha - } - let idx = (3 * 4 + 3) * 4; - px[idx..idx + 4].copy_from_slice(&corner); - Frame::new(4, 4, px).expect("4x4 frame is well-formed") - } - - impl Platform for FakePlatform { - fn start_app(&mut self, spec: &AppSpec) -> Result { - self.specs.lock().unwrap().push(spec.clone()); - self.started = true; - Ok(self.geometry.clone()) - } - fn stop_app_by(&mut self, _deadline: glass_core::Deadline) -> Result<()> { - self.started = false; - Ok(()) - } - fn capture_frame(&mut self, region: Option<&Region>) -> Result { - *self.captures.lock().unwrap() += 1; - let frame = match self.frames.pop_front() { - Some(f) => { - if self.frames.is_empty() { - self.frames.push_back(f.clone()); - } - f - } - None => return Err(GlassError::CaptureFailed("no scripted frames".into())), - }; - match region { - Some(r) => frame.crop(r), - None => Ok(frame), - } - } - fn send_pointer(&mut self, e: &PointerEvent) -> Result<()> { - self.events.lock().unwrap().push(match e { - PointerEvent::Click { x, y, .. } => format!("click({x},{y})"), - PointerEvent::Move { x, y } => format!("move({x},{y})"), - PointerEvent::Drag { - from_x, - from_y, - to_x, - to_y, - .. - } => { - format!("drag({from_x},{from_y}->{to_x},{to_y})") - } - PointerEvent::Scroll { x, y, dx, dy, .. } => format!("scroll({x},{y},{dx},{dy})"), - PointerEvent::Gesture { pointers, .. } => format!("gesture({})", pointers.len()), - }); - self.pointer_events.push(e.clone()); - Ok(()) - } - fn send_key(&mut self, e: &KeyEvent) -> Result<()> { - self.events.lock().unwrap().push(match e { - KeyEvent::Text(t) => format!("type({t})"), - KeyEvent::Chord(c) => format!("key({c})"), - }); - self.key_events.push(e.clone()); - Ok(()) - } - fn window(&mut self, op: &WindowOp) -> Result { - match *op { - WindowOp::Resize { width, height } => { - self.geometry.width = width; - self.geometry.height = height; - } - WindowOp::Move { x, y } => { - self.geometry.x = x; - self.geometry.y = y; - } - WindowOp::Focus | WindowOp::Geometry => {} - } - Ok(self.geometry.clone()) - } - fn list_windows(&mut self) -> Result> { - Ok(vec![WindowInfo { - id: WindowId(0), - title: Some("fake".into()), - class: None, - geometry: self.geometry.clone(), - active: true, - }]) - } - fn select_window(&mut self, id: WindowId) -> Result { - if id == WindowId(0) { - Ok(self.geometry.clone()) - } else { - Err(GlassError::WindowNotFound) - } - } - fn drain_logs(&mut self) -> Vec<(Stream, String)> { - std::mem::take(&mut self.pending_logs) - } - fn get_clipboard(&mut self) -> Result { - Ok(self.clipboard.clone()) - } - fn set_clipboard(&mut self, text: &str) -> Result<()> { - self.clipboard = text.to_string(); - Ok(()) - } - } - - /// Build a `Glass` over a `FakePlatform` with a throwaway baseline dir. - pub fn glass_with(platform: FakePlatform) -> Glass { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("baselines"); - std::mem::forget(dir); // keep the dir alive for the test - // Factory yields the pre-scripted platform once. - let mut held: Option> = Some(Box::new(platform)); - let factory: PlatformFactory = Box::new(move |_backend| { - let platform = held - .take() - .ok_or_else(|| GlassError::Backend("test factory called twice".into()))?; - Ok(Backend::display_only(platform)) - }); - Glass::new(factory, "x11".into(), BaselineStore::new(root), 100) - } - - /// What `FakeAccessibility::set_value` should do — lets a test model the - /// backend rejecting a write (element not editable, or changed since the - /// snapshot) so the tool layer's error propagation can be exercised. - #[derive(Clone, Copy, Default, PartialEq)] - pub enum SetOutcome { - #[default] - Ok, - NotEditable, - Changed, - } - - /// What `FakeAccessibility::invoke` should do. Default mirrors the trait's own - /// default (unsupported) — a backend that never implemented the native action, - /// so `click_element` falls back to the pointer path unless a test opts into - /// [`InvokeOutcome::Ok`]. - #[derive(Clone, Copy, Default, PartialEq)] - pub enum InvokeOutcome { - #[default] - Unsupported, - Ok, - /// The native action fired on a different element than the one named. - OkOnAnother(u32), - } - - pub struct FakeAccessibility { - pub tree: AxTree, - pub set_log: std::sync::Arc>>, - pub set_outcome: SetOutcome, - pub invoke_outcome: InvokeOutcome, - } - - impl Accessibility for FakeAccessibility { - fn snapshot(&mut self, _ctx: &AxContext) -> Result { - Ok(self.tree.clone()) - } - fn set_value(&mut self, _ctx: &AxContext, target: &AxTarget, text: &str) -> Result<()> { - match self.set_outcome { - SetOutcome::NotEditable => { - return Err(GlassError::AxElementNotEditable(target.id.0)); - } - SetOutcome::Changed => return Err(GlassError::AxElementChanged(target.id.0)), - SetOutcome::Ok => {} - } - self.set_log - .lock() - .unwrap() - .push((target.clone(), text.to_string())); - Ok(()) - } - fn invoke(&mut self, _ctx: &AxContext, _target: &AxTarget) -> Result> { - match self.invoke_outcome { - InvokeOutcome::Unsupported => Err(GlassError::AxUnsupported), - InvokeOutcome::Ok => Ok(None), - InvokeOutcome::OkOnAnother(id) => Ok(Some(AxNodeId(id))), - } - } - } - - /// A Window #0 with a Button "Save" child at (10,10 20x20). - pub fn fake_tree() -> AxTree { - let button = AxNode { - id: AxNodeId(0), - role: AxRole::Button, - raw_role: "push button".into(), - name: Some("Save".into()), - description: None, - value: None, - states: AxStates { - focusable: true, - enabled: true, - ..Default::default() - }, - bounds: Some(AxRect { - x: 10, - y: 10, - width: 20, - height: 20, - }), - children: vec![], - }; - let root = AxNode { - id: AxNodeId(0), - role: AxRole::Window, - raw_role: "frame".into(), - name: Some("Win".into()), - description: None, - value: None, - states: AxStates::default(), - bounds: Some(AxRect { - x: 0, - y: 0, - width: 100, - height: 100, - }), - children: vec![button], - }; - AxTree::new(root) - } - - /// A window root with no child elements — the "app publishes no usable tree" shape. - pub fn empty_tree() -> AxTree { - let root = AxNode { - id: AxNodeId(0), - role: AxRole::Window, - raw_role: "frame".into(), - name: Some("Win".into()), - description: None, - value: None, - states: AxStates::default(), - bounds: Some(AxRect { - x: 0, - y: 0, - width: 100, - height: 100, - }), - children: vec![], - }; - AxTree::new(root) - } - - /// `fake_tree` with `truncated` set — the "walk stopped early" shape, for testing that - /// the truncation steer surfaces as its own trusted block rather than being baked into - /// the untrusted-wrapped outline. - pub fn truncated_tree() -> AxTree { - let mut t = fake_tree(); - t.truncated = Some(Truncation { - limit: TruncationLimit::Nodes, - limit_value: 1500, - nodes_walked: 1500, - }); - t - } - - /// `fake_tree` with a childless `Document` child — the unpublished-web-content shape. - pub fn unpublished_document_tree() -> AxTree { - let mut t = fake_tree(); - t.root.children.push(AxNode { - id: AxNodeId(0), - role: AxRole::Document, - raw_role: "document web".into(), - name: Some("page".into()), - description: None, - value: None, - states: AxStates::default(), - bounds: Some(AxRect { - x: 0, - y: 40, - width: 100, - height: 60, - }), - children: vec![], - }); - t.assign_ids(); - t - } - - pub fn glass_with_a11y(platform: FakePlatform, tree: AxTree) -> Glass { - glass_with_a11y_outcome(platform, tree, SetOutcome::Ok) - } - - /// Like [`glass_with_a11y`] but with a chosen `set_value` outcome, so a test can - /// drive the not-editable / changed-since-snapshot rejection paths. `invoke` stays - /// at its default (unsupported) — use [`glass_with_a11y_invoke_ok`] for the - /// native-action path. - pub fn glass_with_a11y_outcome( - platform: FakePlatform, - tree: AxTree, - set_outcome: SetOutcome, - ) -> Glass { - glass_with_a11y_full(platform, tree, set_outcome, InvokeOutcome::Unsupported) - } - - /// Like [`glass_with_a11y`] but with `invoke` wired to succeed, so a test can drive - /// `click_element`'s native-action path (no pointer event, no fallback disclosed). - pub fn glass_with_a11y_invoke_ok(platform: FakePlatform, tree: AxTree) -> Glass { - glass_with_a11y_full(platform, tree, SetOutcome::Ok, InvokeOutcome::Ok) - } - - /// [`glass_with_a11y_invoke_ok`] for a backend that actuates element `actuated` when - /// asked for another one. - pub fn glass_with_a11y_invoke_on_another( - platform: FakePlatform, - tree: AxTree, - actuated: u32, - ) -> Glass { - glass_with_a11y_full( - platform, - tree, - SetOutcome::Ok, - InvokeOutcome::OkOnAnother(actuated), - ) - } - - fn glass_with_a11y_full( - platform: FakePlatform, - tree: AxTree, - set_outcome: SetOutcome, - invoke_outcome: InvokeOutcome, - ) -> Glass { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("baselines"); - std::mem::forget(dir); - let mut held: Option = Some(Backend { - platform: Box::new(platform), - accessibility: Some(Box::new(FakeAccessibility { - tree, - set_log: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), - set_outcome, - invoke_outcome, - })), - }); - let factory: PlatformFactory = Box::new(move |_backend| { - held.take() - .ok_or_else(|| GlassError::Backend("test factory called twice".into())) - }); - Glass::new(factory, "x11".into(), BaselineStore::new(root), 100) - } - - /// Parse content block `i` as the `{ok,tool,result}` envelope. - pub(crate) fn envelope_at(out: &ToolOutput, i: usize) -> serde_json::Value { - let OutContent::Text(t) = &out.0[i] else { - panic!("expected envelope text at block {i}") - }; - serde_json::from_str(t).expect("envelope must be valid JSON") - } - - /// Assert block 0 is the success envelope for `tool` — and that `tool` is a REGISTERED - /// `#[tool]` name, so a co-typo shared between the tool impl's envelope literal and the - /// test's expected string (both say `"glass_stopp"`) still fails loudly. Returns `result`. - pub(crate) fn assert_envelope(out: &ToolOutput, tool: &str) -> serde_json::Value { - let v = envelope_at(out, 0); - assert_eq!(v["ok"], serde_json::json!(true), "envelope: {v}"); - assert_eq!(v["tool"], serde_json::json!(tool), "envelope: {v}"); - assert!( - crate::server::registered_tools().iter().any(|t| t == tool), - "envelope tool {tool:?} is not a registered #[tool]" - ); - v["result"].clone() - } -} +pub(crate) mod testutil; #[cfg(test)] -mod tests { - use super::testutil::*; - use super::*; - use glass_core::{AppSpec, SandboxLevel}; - - fn start_args() -> StartArgs { - StartArgs { - build: None, - run: vec!["app".into()], - backend: None, - sandbox: None, - cwd: None, - env: std::collections::BTreeMap::new(), - window_hint: None, - timeout_ms: None, - a11y: None, - } - } - - #[test] - fn a11y_defaults_on_when_omitted() { - // The a11y-first path is the low-token default, so an omitted flag enables it. - assert!(resolve_a11y(None), "omitted a11y must default on"); - assert!(resolve_a11y(Some(true))); - assert!(!resolve_a11y(Some(false)), "explicit false opts out"); - } - - #[test] - fn floor_unset_preserves_current_behavior() { - // arg wins over env; omit → GLASS_SANDBOX else default. Floor off = no enforcement. - assert_eq!( - resolve_sandbox(Some("off"), Some("strict"), None).unwrap(), - SandboxLevel::Off - ); - assert_eq!( - resolve_sandbox(None, Some("strict"), None).unwrap(), - SandboxLevel::Strict - ); - assert_eq!( - resolve_sandbox(None, None, None).unwrap(), - SandboxLevel::Default - ); - } - - #[test] - fn floor_clamps_an_omitted_request_up() { - // omit-default default, floor strict → effective strict (policy applies, no error). - assert_eq!( - resolve_sandbox(None, None, Some("strict")).unwrap(), - SandboxLevel::Strict - ); - assert_eq!( - resolve_sandbox(None, Some("off"), Some("default")).unwrap(), - SandboxLevel::Default - ); - } - - #[test] - fn floor_honors_an_explicit_request_at_or_above_it() { - assert_eq!( - resolve_sandbox(Some("strict"), None, Some("default")).unwrap(), - SandboxLevel::Strict - ); - assert_eq!( - resolve_sandbox(Some("default"), None, Some("default")).unwrap(), - SandboxLevel::Default - ); - } - - #[test] - fn floor_refuses_an_explicit_request_below_it() { - let err = resolve_sandbox(Some("off"), None, Some("strict")).unwrap_err(); - assert!(err.contains("GLASS_SANDBOX_FLOOR=strict"), "{err}"); - assert!(err.contains("off"), "{err}"); - assert!(resolve_sandbox(Some("default"), None, Some("strict")).is_err()); - } - - #[test] - fn invalid_floor_or_level_is_an_error() { - assert!(resolve_sandbox(None, None, Some("bogus")).is_err()); - assert!(resolve_sandbox(Some("bogus"), None, None).is_err()); - } - - #[test] - fn floor_from_var_maps_present_absent_and_non_utf8() { - // Present + valid → Some; absent → None (no floor). - assert_eq!( - floor_from_var(Ok("strict".to_string())).unwrap(), - Some("strict".to_string()) - ); - assert_eq!( - floor_from_var(Err(std::env::VarError::NotPresent)).unwrap(), - None - ); - // Set-but-non-UTF-8 must be an ERROR (fail-closed), never silently unset (fail-open) — - // otherwise a garbled operator floor would silently disable the policy. - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - let bad = std::ffi::OsString::from_vec(vec![0x73, 0x80, 0x74]); // invalid UTF-8 - assert!(floor_from_var(Err(std::env::VarError::NotUnicode(bad))).is_err()); - } - } - - #[test] - fn start_returns_geometry_json() { - let mut g = glass_with(FakePlatform::new(80, 60)); - let out = start(&mut g, &start_args()).unwrap(); - let v = assert_envelope(&out, "glass_start"); - assert_eq!(v["width"], json!(80)); - assert_eq!(v["height"], json!(60)); - } - - /// The link a wayland smoke run rests on: `glass_start`'s `env` argument - /// reaching the backend as `AppSpec.env`. Every smoke check passes with that env removed, so - /// no run observes it. - #[test] - fn start_hands_the_requested_env_to_the_backend() { - use std::sync::{Arc, Mutex}; - let specs: Arc>> = Arc::new(Mutex::new(Vec::new())); - let mut g = glass_with(FakePlatform::new(10, 10).with_spec_log(specs.clone())); - let mut a = start_args(); - a.env.insert("GDK_BACKEND".into(), "wayland".into()); - start(&mut g, &a).unwrap(); - assert_eq!( - specs.lock().unwrap()[0].env, - vec![("GDK_BACKEND".to_string(), "wayland".to_string())] - ); - } - - #[test] - fn start_rejects_empty_run() { - let mut g = glass_with(FakePlatform::new(10, 10)); - let mut a = start_args(); - a.run.clear(); - assert!(start(&mut g, &a).is_err()); - } - - #[test] - fn start_rejects_unknown_sandbox() { - // Locks rejection at the `glass_start` tool boundary (not just the - // `resolve_sandbox`/`SandboxLevel::FromStr` units below it) — an unknown - // `sandbox` must not be silently coerced to the default level. - let mut g = glass_with(FakePlatform::new(10, 10)); - let mut a = start_args(); - a.sandbox = Some("bogus".into()); - let err = start(&mut g, &a).unwrap_err(); - assert!(err.contains("unknown sandbox level"), "got: {err}"); - } - - #[test] - fn stop_without_session_errors_with_message() { - let mut g = glass_with(FakePlatform::new(10, 10)); - let err = stop(&mut g).unwrap_err(); - assert!(err.contains("no active session")); - } - - #[test] - fn stop_running_session_returns_empty_envelope() { - let mut g = glass_with(FakePlatform::new(10, 10)); - start(&mut g, &start_args()).unwrap(); - let out = stop(&mut g).unwrap(); - let v = assert_envelope(&out, "glass_stop"); - assert_eq!(v, json!({}), "envelope: {v}"); - } - - #[test] - fn window_resize_requires_dimensions() { - let mut g = glass_with(FakePlatform::new(10, 10)); - start(&mut g, &start_args()).unwrap(); - let a = WindowArgs { - op: "resize".into(), - x: None, - y: None, - width: None, - height: None, - }; - assert!(window(&mut g, &a).unwrap_err().contains("width")); - } - - #[test] - fn window_resize_updates_and_returns_geometry() { - let mut g = glass_with(FakePlatform::new(10, 10)); - start(&mut g, &start_args()).unwrap(); - let a = WindowArgs { - op: "resize".into(), - x: None, - y: None, - width: Some(33), - height: Some(44), - }; - let out = window(&mut g, &a).unwrap(); - let v = assert_envelope(&out, "glass_window"); - assert_eq!(v["width"], json!(33)); - assert_eq!(v["height"], json!(44)); - } - - #[test] - fn window_rejects_unknown_op() { - let mut g = glass_with(FakePlatform::new(10, 10)); - start(&mut g, &start_args()).unwrap(); - let a = WindowArgs { - op: "levitate".into(), - x: None, - y: None, - width: None, - height: None, - }; - let err = window(&mut g, &a).unwrap_err(); - assert!(err.contains("unknown window op"), "got: {err}"); - } - - #[test] - fn parse_button_maps_and_rejects() { - assert!(matches!( - parse_button(Some("middle")), - Ok(MouseButton::Middle) - )); - assert!(matches!(parse_button(None), Ok(MouseButton::Left))); - assert!(parse_button(Some("nope")).is_err()); - } - - #[test] - fn a11y_snapshot_returns_outline_text() { - let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert_envelope(&out, "glass_a11y_snapshot"); - match &out.0[1] { - OutContent::Text(t) => { - assert!( - t.starts_with(crate::untrusted::NOTE), - "must be marked untrusted: {t}" - ); - assert!( - t.contains("⟦untrusted:") && t.contains("⟦/untrusted:"), - "enveloped: {t}" - ); - assert!(t.contains("#0 Window"), "outline: {t}"); - assert!( - t.contains("#1 Button \"Save\" (10,10 20x20)"), - "outline: {t}" - ); - } - _ => panic!("expected text"), - } - } - - #[test] - fn a11y_snapshot_appends_pixel_hint_when_treeless() { - let mut g = glass_with_a11y(FakePlatform::new(100, 100), empty_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert_envelope(&out, "glass_a11y_snapshot"); - // [0]=envelope, [1]=untrusted root-only outline, [2]=glass's trusted pixel hint. - match &out.0[2] { - OutContent::Text(t) => { - assert!(t.contains("glass_screenshot"), "pixel hint: {t}"); - assert!( - !t.starts_with(crate::untrusted::NOTE), - "the hint is glass's own guidance, not untrusted app content: {t}" - ); - } - _ => panic!("expected the pixel-hint text"), - } - } - - #[test] - fn a11y_snapshot_truncation_steer_is_a_trusted_block_outside_the_untrusted_envelope() { - // glass's own truncation steer must not be baked into `render_compact`'s output, or - // it ends up inside the untrusted envelope — under a directive telling the agent to - // ignore instructions in that block, even though the steer ("drive by pixels…") IS - // one of glass's own. It gets its own trusted, unwrapped block. - let mut g = glass_with_a11y(FakePlatform::new(100, 100), truncated_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert_envelope(&out, "glass_a11y_snapshot"); - // [0]=envelope, [1]=untrusted-wrapped outline, [2]=glass's trusted truncation steer. - assert_eq!( - out.0.len(), - 3, - "envelope + wrapped outline + trusted truncation steer" - ); - match &out.0[1] { - OutContent::Text(t) => { - assert!( - t.starts_with(crate::untrusted::NOTE) - && t.contains("⟦untrusted:") - && t.contains("⟦/untrusted:"), - "the outline itself stays untrusted-wrapped: {t}" - ); - assert!( - !t.contains("truncated"), - "the truncation notice must NOT be baked into the untrusted-wrapped \ - outline body: {t}" - ); - } - _ => panic!("expected the wrapped outline text"), - } - match &out.0[2] { - OutContent::Text(t) => { - assert!(t.contains("truncated"), "truncation steer: {t}"); - assert!( - t.contains("glass_screenshot"), - "the steer names the pixel fallback: {t}" - ); - assert!( - !t.starts_with(crate::untrusted::NOTE) && !t.contains("⟦untrusted:"), - "the steer is glass's own trusted guidance, outside the untrusted \ - markers entirely: {t}" - ); - } - _ => panic!("expected the trusted truncation-steer text"), - } - } - - #[test] - fn a11y_snapshot_discloses_an_unpublished_document_as_a_trusted_block() { - let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert_envelope(&out, "glass_a11y_snapshot"); - assert_eq!( - out.0.len(), - 3, - "envelope + wrapped outline + document guidance" - ); - match (&out.0[1], &out.0[2]) { - (OutContent::Text(body), OutContent::Text(steer)) => { - assert!( - !body.contains("has no readable content"), - "guidance must not be inside the untrusted body: {body}" - ); - assert!(steer.contains("Document"), "{steer}"); - assert!(steer.contains("glass_screenshot"), "{steer}"); - } - other => panic!("unexpected blocks: {other:?}"), - } - } - - #[test] - fn a11y_snapshot_discloses_withheld_content_as_a_trusted_block() { - let mut tree = fake_tree(); - tree.unexposed = 1; - let mut g = glass_with_a11y(FakePlatform::new(100, 100), tree); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert_envelope(&out, "glass_a11y_snapshot"); - assert_eq!( - out.0.len(), - 3, - "envelope + wrapped outline + the withheld-content steer" - ); - match (&out.0[1], &out.0[2]) { - (OutContent::Text(body), OutContent::Text(steer)) => { - assert!( - !body.contains("placeholder"), - "the steer must not be inside the untrusted body: {body}" - ); - assert!(steer.contains("has not exposed"), "{steer}"); - assert!(steer.contains("glass_screenshot"), "{steer}"); - assert!( - !steer.starts_with(crate::untrusted::NOTE) && !steer.contains("⟦untrusted:"), - "glass's own guidance stays outside the untrusted markers: {steer}" - ); - } - other => panic!("unexpected blocks: {other:?}"), - } - } - - #[test] - fn the_withheld_content_steer_sits_between_the_unreadable_one_and_the_document_one() { - // All three can fire on one tree. - let mut tree = unpublished_document_tree(); - tree.unreadable = 1; - tree.unexposed = 1; - let steers = a11y_steers(&tree); - let at = |needle: &str| { - steers - .iter() - .position(|s| s.contains(needle)) - .unwrap_or_else(|| panic!("no steer contains {needle:?}: {steers:?}")) - }; - assert!( - at("could not be read") < at("has not exposed"), - "{steers:?}" - ); - assert!( - at("has not exposed") < at("no readable content"), - "{steers:?}" - ); - } - - #[test] - fn a_snapshot_of_another_app_says_so_in_its_text() { - let mut tree = empty_tree(); - tree.subject = Some(glass_core::Subject { - asked: "com.example.app".into(), - actual: "com.google.android.permissioncontroller".into(), - }); - let steers = a11y_steers(&tree); - assert!( - steers - .iter() - .any(|s| s.contains("com.google.android.permissioncontroller")), - "the agent is told which app the ids address: {steers:?}" - ); - } - - #[test] - fn a11y_snapshot_unsupported_message() { - let mut g = glass_with(FakePlatform::new(40, 30)); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let err = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap_err(); - assert!(err.contains("not supported"), "msg: {err}"); - } - - #[test] - fn set_value_tool_ok_and_errors() { - let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let out = set_value( - &mut g, - &SetValueArgs { - id: 1, - text: "hello".into(), - return_: None, - }, - ) - .unwrap(); - let v = assert_envelope(&out, "glass_set_value"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - // unknown id surfaces the actionable message - let err = set_value( - &mut g, - &SetValueArgs { - id: 99, - text: "x".into(), - return_: None, - }, - ) - .unwrap_err(); - assert!(err.contains("not in the current snapshot"), "msg: {err}"); - } - - #[test] - fn set_value_tool_rejects_uneditable_and_stale() { - let spec = AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }; - // Backend says the element isn't editable: the tool must surface an error, - // never the "set value" confirmation (a silent successful-looking no-op is - // the worst failure for an agent that then asserts "value set"). - let mut g = glass_with_a11y_outcome( - FakePlatform::new(100, 100), - fake_tree(), - SetOutcome::NotEditable, - ); - g.start(&spec).unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let err = set_value( - &mut g, - &SetValueArgs { - id: 1, - text: "x".into(), - return_: None, - }, - ) - .unwrap_err(); - assert!(err.contains("not editable"), "msg: {err}"); - - // Element changed since the snapshot: same contract — error, not success. - let mut g = glass_with_a11y_outcome( - FakePlatform::new(100, 100), - fake_tree(), - SetOutcome::Changed, - ); - g.start(&spec).unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let err = set_value( - &mut g, - &SetValueArgs { - id: 1, - text: "x".into(), - return_: None, - }, - ) - .unwrap_err(); - assert!(err.contains("changed since the snapshot"), "msg: {err}"); - } - - #[test] - fn click_element_tool_ok_and_errors() { - let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - assert!( - click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None - } - ) - .is_ok() - ); - let err = click_element( - &mut g, - &ClickElementArgs { - id: 99, - return_: None, - }, - ) - .unwrap_err(); - assert!(err.contains("not in the current snapshot"), "msg: {err}"); - } - - #[test] - fn a11y_marks_returns_image_and_legend() { - use glass_core::Frame; - let platform = - FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let mut g = glass_with_a11y(platform, fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_marks(&mut g).unwrap(); - assert!( - matches!(out.0[0], OutContent::Image(_)), - "first item is the image" - ); - let OutContent::Text(t) = &out.0[1] else { - panic!("expected envelope text as the second item") - }; - let v: serde_json::Value = serde_json::from_str(t).expect("envelope must be valid JSON"); - assert_eq!(v["ok"], json!(true), "envelope: {v}"); - assert_eq!(v["tool"], json!("glass_a11y_marks"), "envelope: {v}"); - assert_eq!(v["result"]["count"], json!(1), "envelope: {v}"); - match &out.0[2] { - OutContent::Text(t) => assert!(t.contains("#1 Button \"Save\""), "legend: {t}"), - _ => panic!("expected legend text"), - } - } - - #[test] - fn a11y_marks_legend_spells_a_description_apart_from_a_name() { - use glass_core::{AxRect, Frame}; - let mut tree = fake_tree(); - let mut icon = tree.root.children[0].clone(); - icon.name = None; - icon.description = Some("Bold".into()); - icon.bounds = Some(AxRect { - x: 40, - y: 10, - width: 20, - height: 20, - }); - tree.root.children.push(icon); - let platform = - FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let mut g = glass_with_a11y(platform, tree); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_marks(&mut g).unwrap(); - let OutContent::Text(legend) = &out.0[2] else { - panic!("expected legend text") - }; - // A real name rides in the quoted slot a `name:` selector matches; a description - // rides in `desc="…"`, exactly as the outline spells it. - assert!(legend.contains("#1 Button \"Save\""), "legend: {legend}"); - assert!( - legend.contains("#2 Button desc=\"Bold\""), - "legend: {legend}" - ); - assert!( - !legend.contains("Button \"Bold\""), - "a description must never render as a name: {legend}" - ); - } - - #[test] - fn a11y_marks_legend_untrusted_wrapped_and_image_note_present() { - use glass_core::Frame; - let platform = - FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let mut g = glass_with_a11y(platform, fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let out = a11y_marks(&mut g).unwrap(); - // [Image, envelope-Text, legend-Text (untrusted-wrapped), IMAGE_NOTE-Text] - assert!( - out.0.len() >= 4, - "expected [Image, envelope, legend, IMAGE_NOTE], got {} items", - out.0.len() - ); - assert!( - matches!(out.0[0], OutContent::Image(_)), - "image leads: {:?}", - out.0 - ); - // the trusted envelope comes right after the image - match &out.0[1] { - OutContent::Text(t) => { - let v: serde_json::Value = - serde_json::from_str(t).expect("envelope must be valid JSON"); - assert_eq!(v["ok"], json!(true), "envelope: {v}"); - assert_eq!(v["tool"], json!("glass_a11y_marks"), "envelope: {v}"); - } - _ => panic!("expected envelope text as second item"), - } - // legend must be untrusted-wrapped - match &out.0[2] { - OutContent::Text(t) => { - assert!( - t.starts_with(crate::untrusted::NOTE), - "legend must start with NOTE: {t}" - ); - assert!( - t.contains("⟦untrusted:") && t.contains("⟦/untrusted:"), - "legend must be untrusted-wrapped: {t}" - ); - assert!( - t.contains("#1 Button"), - "legend must still contain element: {t}" - ); - } - _ => panic!("expected legend text as third item"), - } - // IMAGE_NOTE must be present - let has_note = out - .0 - .iter() - .any(|c| matches!(c, OutContent::Text(t) if t == crate::untrusted::IMAGE_NOTE)); - assert!(has_note, "IMAGE_NOTE must be present in a11y_marks output"); - } - - pub(crate) fn started_a11y_frames(frames: Vec) -> Glass { - let mut g = glass_with_a11y(FakePlatform::new(100, 100).with_frames(frames), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); // populate last_ax for click_element/set_value - g - } - - #[test] - fn return_none_is_confirmation_only() { - let mut g = started_a11y_frames(vec![]); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None, - }, - ) - .unwrap(); - assert_eq!(out.0.len(), 1, "just the envelope, no siblings"); - let v = assert_envelope(&out, "glass_click_element"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert!(v["observed"].is_null(), "envelope: {v}"); - // The fake's default invoke is unsupported (trait default), so the pointer - // path ran and the fallback reason must be disclosed. - assert_eq!(v["method"], json!("pointer"), "envelope: {v}"); - assert!( - v["native_fallback"].as_str().is_some_and(|s| !s.is_empty()), - "envelope: {v}" - ); - - let out2 = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("none".into()), - }, - ) - .unwrap(); - assert_eq!(out2.0.len(), 1); - } - - #[test] - fn click_element_discloses_native_action_with_no_fallback() { - let mut g = glass_with_a11y_invoke_ok(FakePlatform::new(100, 100), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None, - }, - ) - .unwrap(); - let v = assert_envelope(&out, "glass_click_element"); - assert_eq!(v["method"], json!("native-action"), "envelope: {v}"); - assert!( - v.get("native_fallback").is_none(), - "no fallback key on the native-action path: {v}" - ); - } - - #[test] - fn click_element_names_the_element_it_actuated_instead() { - // The backend resolved the click onto a different element. Without this key the - // result cannot distinguish "clicked the label" from "clicked the row around it". - let mut g = glass_with_a11y_invoke_on_another(FakePlatform::new(100, 100), fake_tree(), 7); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None, - }, - ) - .unwrap(); - let v = assert_envelope(&out, "glass_click_element"); - assert_eq!(v["method"], json!("native-action"), "envelope: {v}"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert_eq!(v["actuated_id"], json!(7), "envelope: {v}"); - } - - #[test] - fn click_element_omits_actuated_id_when_the_target_itself_was_clicked() { - let mut g = glass_with_a11y_invoke_ok(FakePlatform::new(100, 100), fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None, - }, - ) - .unwrap(); - let v = assert_envelope(&out, "glass_click_element"); - assert!( - v.get("actuated_id").is_none(), - "nothing was substituted: {v}" - ); - } - - #[test] - fn return_unknown_errors() { - let mut g = started_a11y_frames(vec![]); - let err = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("wat".into()), - }, - ) - .unwrap_err(); - assert!(err.contains("unknown return"), "msg: {err}"); - } - - #[test] - fn return_snapshot_appends_tree_and_refreshes_cache() { - // vec![] → the snapshot arm's best-effort settle can't capture and is swallowed; the - // tree still folds (the assertions below are unaffected by the settle). - let mut g = started_a11y_frames(vec![]); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 2, - "envelope + exactly one sibling (the a11y outline)" - ); - let v = assert_envelope(&out, "glass_click_element"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert!( - v["observed"].is_null(), - "snapshot doesn't populate `observed`: {v}" - ); - match &out.0[1] { - OutContent::Text(t) => { - assert!( - t.starts_with(crate::untrusted::NOTE), - "must be marked untrusted: {t}" - ); - assert!( - t.contains("#1 Button \"Save\""), - "a11y outline appended: {t}" - ); - } - _ => panic!("expected a11y outline text"), - } - // the snapshot refreshed last_ax -> a follow-up id-based action still resolves - assert!( - click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None - } - ) - .is_ok() - ); - } - - #[test] - fn return_snapshot_discloses_an_unpublished_document_the_same_way_a_snapshot_does() { - // The fold's steer wiring, not `a11y_steers` itself: every fold test until now used a - // tree with nothing to disclose, so dropping the `extend` broke nothing. - let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - // Populates the id cache click_element resolves against, and is the parity tree. - let tree = g.a11y_snapshot(None).unwrap(); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - let texts: Vec<&String> = out - .0 - .iter() - .filter_map(|c| match c { - OutContent::Text(t) => Some(t), - _ => None, - }) - .collect(); - let steer = texts - .iter() - .find(|t| t.contains("has no readable content")) - .unwrap_or_else(|| panic!("the fold owes the document guidance: {texts:?}")); - assert!( - !steer.starts_with(crate::untrusted::NOTE) && !steer.contains("⟦untrusted:"), - "glass's own guidance, outside the untrusted envelope: {steer}" - ); - assert!(steer.contains("glass_screenshot"), "{steer}"); - // Parity with `a11y_snapshot`: a fifth steer added to one call site and not the - // other fails here too. - for expected in a11y_steers(&tree) { - assert!( - texts.iter().any(|t| **t == expected), - "the fold dropped a steer: {expected}" - ); - } - } - - #[test] - fn return_snapshot_settles_before_folding() { - use glass_core::Frame; - use std::sync::{Arc, Mutex}; - // A settleable frame + a capture counter, wired inline (started_a11y_frames doesn't - // expose a capture log). - let captures = Arc::new(Mutex::new(0usize)); - let platform = FakePlatform::new(100, 100) - .with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]) - .with_capture_log(captures.clone()); - let mut g = glass_with_a11y(platform, fake_tree()); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); // seed last_ax for click_element - let before = *captures.lock().unwrap(); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - // The a11y outline is still folded (envelope + one untrusted sibling) ... - assert_eq!(out.0.len(), 2, "envelope + a11y outline sibling"); - // ... AND the settle captured frames before the fold. This guards the `wait_stable` - // line: remove it and `captures` stays at `before`. - assert!( - *captures.lock().unwrap() > before, - "return:snapshot must settle (capture frames) before folding" - ); - } - - #[test] - fn return_snapshot_without_frames_still_folds() { - // No frames → the settle's `wait_stable` errors (no scripted frames); the `let _ =` - // swallows it and the tree is still folded. A `?` there would deny the tree. - let mut g = started_a11y_frames(vec![]); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 2, - "tree still folds even when the settle can't run" - ); - } - - #[test] - fn return_settle_appends_settled_text() { - use glass_core::Frame; - // wait_stable needs frames; one solid frame (repeated by the fake) settles. - let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let out = click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: Some("settle".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 1, - "settle folds into `result.observed`, no extra sibling" - ); - let v = assert_envelope(&out, "glass_click_element"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); - } - - #[test] - fn set_value_return_snapshot() { - let mut g = started_a11y_frames(vec![]); - let out = set_value( - &mut g, - &SetValueArgs { - id: 1, - text: "x".into(), - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - let v = assert_envelope(&out, "glass_set_value"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert!( - matches!(&out.0[1], OutContent::Text(t) if t.starts_with(crate::untrusted::NOTE) && t.contains("#1 Button")), - "outline appended" - ); - } - - #[test] - fn set_value_return_settle_folds_into_observed() { - use glass_core::Frame; - // Mirrors `return_settle_appends_settled_text` for `click_element`: wait_stable - // needs frames; one solid frame (repeated by the fake) settles. - let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let out = set_value( - &mut g, - &SetValueArgs { - id: 1, - text: "x".into(), - return_: Some("settle".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 1, - "settle folds into `result.observed`, no extra sibling" - ); - let v = assert_envelope(&out, "glass_set_value"); - assert_eq!(v["id"], json!(1), "envelope: {v}"); - assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); - } - - #[test] - fn type_return_settle_folds_into_observed() { - use glass_core::Frame; - // Mirrors the click_element/set_value settle observes: wait_stable needs frames; - // one solid frame (repeated by the fake) settles. - let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); - let out = type_text( - &mut g, - &TypeArgs { - text: "hi".into(), - return_: Some("settle".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 1, - "settle folds into `result.observed`, no extra sibling" - ); - let v = assert_envelope(&out, "glass_type"); - assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); - } - - #[test] - fn type_return_snapshot_appends_outline() { - let mut g = started_a11y_frames(vec![]); - let out = type_text( - &mut g, - &TypeArgs { - text: "x".into(), - return_: Some("snapshot".into()), - }, - ) - .unwrap(); - assert_eq!( - out.0.len(), - 2, - "envelope + exactly one sibling (the a11y outline)" - ); - assert_envelope(&out, "glass_type"); - assert!( - matches!(&out.0[1], OutContent::Text(t) if t.starts_with(crate::untrusted::NOTE) && t.contains("#1 Button")), - "outline appended" - ); - // the snapshot refreshed last_ax -> a follow-up id-based action still resolves - assert!( - click_element( - &mut g, - &ClickElementArgs { - id: 1, - return_: None - } - ) - .is_ok() - ); - } - - #[test] - fn type_unknown_return_rejected_before_any_keystroke() { - use std::sync::{Arc, Mutex}; - // A bad `return` value must fail BEFORE the text is injected — an agent that - // retries after this error must not end up with the text typed twice. - let log = Arc::new(Mutex::new(Vec::new())); - let mut g = glass_with(FakePlatform::new(100, 100).with_event_log(log.clone())); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let err = type_text( - &mut g, - &TypeArgs { - text: "x".into(), - return_: Some("bogus".into()), - }, - ) - .unwrap_err(); - assert!(err.contains("unknown return"), "msg: {err}"); - assert!( - log.lock().unwrap().is_empty(), - "no input injected on a rejected `return`: {:?}", - log.lock().unwrap() - ); - } - - #[test] - fn type_observe_failure_says_text_was_typed() { - use std::sync::{Arc, Mutex}; - // A runtime observe failure (here: `snapshot` on a session with no a11y reader) - // happens AFTER the keystrokes landed. The error must say so, or an agent - // retries the whole call and the field ends up with the text twice. - let log = Arc::new(Mutex::new(Vec::new())); - let mut g = glass_with(FakePlatform::new(100, 100).with_event_log(log.clone())); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - let err = type_text( - &mut g, - &TypeArgs { - text: "hi".into(), - return_: Some("snapshot".into()), - }, - ) - .unwrap_err(); - assert!(err.contains("text was typed"), "msg: {err}"); - assert_eq!(*log.lock().unwrap(), vec!["type(hi)"], "keystrokes landed"); - } - - #[test] - fn list_and_select_window_tools() { - let mut g = glass_with(FakePlatform::new(320, 240)); - g.start(&AppSpec { - build: None, - run: vec!["x".into()], - cwd: None, - env: vec![], - window_hint: None, - timeout_ms: 1, - sandbox: SandboxLevel::Off, - a11y: false, - }) - .unwrap(); - - let out = list_windows(&mut g).unwrap(); - let v = assert_envelope(&out, "glass_list_windows"); - assert_eq!(v["count"], json!(1), "envelope: {v}"); - let text = match &out.0[1] { - OutContent::Text(t) => t.clone(), - _ => panic!("expected text"), - }; - assert!( - text.starts_with(crate::untrusted::NOTE), - "must be marked untrusted: {text}" - ); - assert!( - text.contains("⟦untrusted:") && text.contains("⟦/untrusted:"), - "enveloped: {text}" - ); - assert!( - text.contains("\"id\":0"), - "json should list window id 0: {text}" - ); - assert!( - text.contains("\"active\":true"), - "json should mark active: {text}" - ); - assert!( - text.contains("\"width\":320"), - "json should include geometry width: {text}" - ); - - let out = select_window(&mut g, &SelectWindowArgs { id: 0 }).unwrap(); - let v = assert_envelope(&out, "glass_select_window"); - assert_eq!(v["width"], json!(320), "envelope: {v}"); - assert_eq!(v["height"], json!(240), "envelope: {v}"); - assert!(select_window(&mut g, &SelectWindowArgs { id: 42 }).is_err()); - } - - #[test] - fn result_envelope_is_leading_and_shaped() { - let out = ToolOutput::result("glass_stop", serde_json::json!({})); - let OutContent::Text(t) = &out.0[0] else { - panic!("expected text") - }; - let v: serde_json::Value = serde_json::from_str(t).unwrap(); - assert_eq!(v["ok"], serde_json::json!(true)); - assert_eq!(v["tool"], serde_json::json!("glass_stop")); - assert_eq!(v["result"], serde_json::json!({})); - } - - #[test] - fn result_with_puts_envelope_first_then_extra() { - let out = ToolOutput::result_with( - "glass_screenshot", - serde_json::json!({ "width": 4, "height": 4 }), - vec![OutContent::Image(vec![1, 2, 3])], - ); - assert!(matches!(out.0[0], OutContent::Text(_)), "envelope leads"); - assert!(matches!(out.0[1], OutContent::Image(_)), "extra follows"); - } -} +mod tests; diff --git a/crates/glass-mcp/src/tools/tests.rs b/crates/glass-mcp/src/tools/tests.rs new file mode 100644 index 00000000..8cc783ff --- /dev/null +++ b/crates/glass-mcp/src/tools/tests.rs @@ -0,0 +1,1333 @@ +use super::testutil::*; +use super::*; +use glass_core::{AppSpec, SandboxLevel}; + +fn start_args() -> StartArgs { + StartArgs { + build: None, + run: vec!["app".into()], + backend: None, + sandbox: None, + cwd: None, + env: std::collections::BTreeMap::new(), + window_hint: None, + timeout_ms: None, + a11y: None, + } +} + +#[test] +fn a11y_defaults_on_when_omitted() { + // The a11y-first path is the low-token default, so an omitted flag enables it. + assert!(resolve_a11y(None), "omitted a11y must default on"); + assert!(resolve_a11y(Some(true))); + assert!(!resolve_a11y(Some(false)), "explicit false opts out"); +} + +#[test] +fn floor_unset_preserves_current_behavior() { + // arg wins over env; omit → GLASS_SANDBOX else default. Floor off = no enforcement. + assert_eq!( + resolve_sandbox(Some("off"), Some("strict"), None).unwrap(), + SandboxLevel::Off + ); + assert_eq!( + resolve_sandbox(None, Some("strict"), None).unwrap(), + SandboxLevel::Strict + ); + assert_eq!( + resolve_sandbox(None, None, None).unwrap(), + SandboxLevel::Default + ); +} + +#[test] +fn floor_clamps_an_omitted_request_up() { + // omit-default default, floor strict → effective strict (policy applies, no error). + assert_eq!( + resolve_sandbox(None, None, Some("strict")).unwrap(), + SandboxLevel::Strict + ); + assert_eq!( + resolve_sandbox(None, Some("off"), Some("default")).unwrap(), + SandboxLevel::Default + ); +} + +#[test] +fn floor_honors_an_explicit_request_at_or_above_it() { + assert_eq!( + resolve_sandbox(Some("strict"), None, Some("default")).unwrap(), + SandboxLevel::Strict + ); + assert_eq!( + resolve_sandbox(Some("default"), None, Some("default")).unwrap(), + SandboxLevel::Default + ); +} + +#[test] +fn floor_refuses_an_explicit_request_below_it() { + let err = resolve_sandbox(Some("off"), None, Some("strict")).unwrap_err(); + assert!(err.contains("GLASS_SANDBOX_FLOOR=strict"), "{err}"); + assert!(err.contains("off"), "{err}"); + assert!(resolve_sandbox(Some("default"), None, Some("strict")).is_err()); +} + +#[test] +fn invalid_floor_or_level_is_an_error() { + assert!(resolve_sandbox(None, None, Some("bogus")).is_err()); + assert!(resolve_sandbox(Some("bogus"), None, None).is_err()); +} + +#[test] +fn floor_from_var_maps_present_absent_and_non_utf8() { + // Present + valid → Some; absent → None (no floor). + assert_eq!( + floor_from_var(Ok("strict".to_string())).unwrap(), + Some("strict".to_string()) + ); + assert_eq!( + floor_from_var(Err(std::env::VarError::NotPresent)).unwrap(), + None + ); + // Set-but-non-UTF-8 must be an ERROR (fail-closed), never silently unset (fail-open) — + // otherwise a garbled operator floor would silently disable the policy. + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let bad = std::ffi::OsString::from_vec(vec![0x73, 0x80, 0x74]); // invalid UTF-8 + assert!(floor_from_var(Err(std::env::VarError::NotUnicode(bad))).is_err()); + } +} + +#[test] +fn start_returns_geometry_json() { + let mut g = glass_with(FakePlatform::new(80, 60)); + let out = start(&mut g, &start_args()).unwrap(); + let v = assert_envelope(&out, "glass_start"); + assert_eq!(v["width"], json!(80)); + assert_eq!(v["height"], json!(60)); +} + +/// The link a wayland smoke run rests on: `glass_start`'s `env` argument +/// reaching the backend as `AppSpec.env`. Every smoke check passes with that env removed, so +/// no run observes it. +#[test] +fn start_hands_the_requested_env_to_the_backend() { + use std::sync::{Arc, Mutex}; + let specs: Arc>> = Arc::new(Mutex::new(Vec::new())); + let mut g = glass_with(FakePlatform::new(10, 10).with_spec_log(specs.clone())); + let mut a = start_args(); + a.env.insert("GDK_BACKEND".into(), "wayland".into()); + start(&mut g, &a).unwrap(); + assert_eq!( + specs.lock().unwrap()[0].env, + vec![("GDK_BACKEND".to_string(), "wayland".to_string())] + ); +} + +#[test] +fn start_rejects_empty_run() { + let mut g = glass_with(FakePlatform::new(10, 10)); + let mut a = start_args(); + a.run.clear(); + assert!(start(&mut g, &a).is_err()); +} + +#[test] +fn start_rejects_unknown_sandbox() { + // Locks rejection at the `glass_start` tool boundary (not just the + // `resolve_sandbox`/`SandboxLevel::FromStr` units below it) — an unknown + // `sandbox` must not be silently coerced to the default level. + let mut g = glass_with(FakePlatform::new(10, 10)); + let mut a = start_args(); + a.sandbox = Some("bogus".into()); + let err = start(&mut g, &a).unwrap_err(); + assert!(err.contains("unknown sandbox level"), "got: {err}"); +} + +#[test] +fn stop_without_session_errors_with_message() { + let mut g = glass_with(FakePlatform::new(10, 10)); + let err = stop(&mut g).unwrap_err(); + assert!(err.contains("no active session")); +} + +#[test] +fn stop_running_session_returns_empty_envelope() { + let mut g = glass_with(FakePlatform::new(10, 10)); + start(&mut g, &start_args()).unwrap(); + let out = stop(&mut g).unwrap(); + let v = assert_envelope(&out, "glass_stop"); + assert_eq!(v, json!({}), "envelope: {v}"); +} + +#[test] +fn window_resize_requires_dimensions() { + let mut g = glass_with(FakePlatform::new(10, 10)); + start(&mut g, &start_args()).unwrap(); + let a = WindowArgs { + op: "resize".into(), + x: None, + y: None, + width: None, + height: None, + }; + assert!(window(&mut g, &a).unwrap_err().contains("width")); +} + +#[test] +fn window_resize_updates_and_returns_geometry() { + let mut g = glass_with(FakePlatform::new(10, 10)); + start(&mut g, &start_args()).unwrap(); + let a = WindowArgs { + op: "resize".into(), + x: None, + y: None, + width: Some(33), + height: Some(44), + }; + let out = window(&mut g, &a).unwrap(); + let v = assert_envelope(&out, "glass_window"); + assert_eq!(v["width"], json!(33)); + assert_eq!(v["height"], json!(44)); +} + +#[test] +fn window_rejects_unknown_op() { + let mut g = glass_with(FakePlatform::new(10, 10)); + start(&mut g, &start_args()).unwrap(); + let a = WindowArgs { + op: "levitate".into(), + x: None, + y: None, + width: None, + height: None, + }; + let err = window(&mut g, &a).unwrap_err(); + assert!(err.contains("unknown window op"), "got: {err}"); +} + +#[test] +fn parse_button_maps_and_rejects() { + assert!(matches!( + parse_button(Some("middle")), + Ok(MouseButton::Middle) + )); + assert!(matches!(parse_button(None), Ok(MouseButton::Left))); + assert!(parse_button(Some("nope")).is_err()); +} + +#[test] +fn a11y_snapshot_returns_outline_text() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + match &out.0[1] { + OutContent::Text(t) => { + assert!( + t.starts_with(crate::untrusted::NOTE), + "must be marked untrusted: {t}" + ); + assert!( + t.contains("⟦untrusted:") && t.contains("⟦/untrusted:"), + "enveloped: {t}" + ); + assert!(t.contains("#0 Window"), "outline: {t}"); + assert!( + t.contains("#1 Button \"Save\" (10,10 20x20)"), + "outline: {t}" + ); + } + _ => panic!("expected text"), + } +} + +#[test] +fn a11y_snapshot_appends_pixel_hint_when_treeless() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), empty_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + // [0]=envelope, [1]=untrusted root-only outline, [2]=glass's trusted pixel hint. + match &out.0[2] { + OutContent::Text(t) => { + assert!(t.contains("glass_screenshot"), "pixel hint: {t}"); + assert!( + !t.starts_with(crate::untrusted::NOTE), + "the hint is glass's own guidance, not untrusted app content: {t}" + ); + } + _ => panic!("expected the pixel-hint text"), + } +} + +#[test] +fn a11y_snapshot_truncation_steer_is_a_trusted_block_outside_the_untrusted_envelope() { + // glass's own truncation steer must not be baked into `render_compact`'s output, or + // it ends up inside the untrusted envelope — under a directive telling the agent to + // ignore instructions in that block, even though the steer ("drive by pixels…") IS + // one of glass's own. It gets its own trusted, unwrapped block. + let mut g = glass_with_a11y(FakePlatform::new(100, 100), truncated_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + // [0]=envelope, [1]=untrusted-wrapped outline, [2]=glass's trusted truncation steer. + assert_eq!( + out.0.len(), + 3, + "envelope + wrapped outline + trusted truncation steer" + ); + match &out.0[1] { + OutContent::Text(t) => { + assert!( + t.starts_with(crate::untrusted::NOTE) + && t.contains("⟦untrusted:") + && t.contains("⟦/untrusted:"), + "the outline itself stays untrusted-wrapped: {t}" + ); + assert!( + !t.contains("truncated"), + "the truncation notice must NOT be baked into the untrusted-wrapped \ + outline body: {t}" + ); + } + _ => panic!("expected the wrapped outline text"), + } + match &out.0[2] { + OutContent::Text(t) => { + assert!(t.contains("truncated"), "truncation steer: {t}"); + assert!( + t.contains("glass_screenshot"), + "the steer names the pixel fallback: {t}" + ); + assert!( + !t.starts_with(crate::untrusted::NOTE) && !t.contains("⟦untrusted:"), + "the steer is glass's own trusted guidance, outside the untrusted \ + markers entirely: {t}" + ); + } + _ => panic!("expected the trusted truncation-steer text"), + } +} + +#[test] +fn a11y_snapshot_discloses_an_unpublished_document_as_a_trusted_block() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + assert_eq!( + out.0.len(), + 3, + "envelope + wrapped outline + document guidance" + ); + match (&out.0[1], &out.0[2]) { + (OutContent::Text(body), OutContent::Text(steer)) => { + assert!( + !body.contains("has no readable content"), + "guidance must not be inside the untrusted body: {body}" + ); + assert!(steer.contains("Document"), "{steer}"); + assert!(steer.contains("glass_screenshot"), "{steer}"); + } + other => panic!("unexpected blocks: {other:?}"), + } +} + +#[test] +fn a11y_snapshot_discloses_withheld_content_as_a_trusted_block() { + let mut tree = fake_tree(); + tree.unexposed = 1; + let mut g = glass_with_a11y(FakePlatform::new(100, 100), tree); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + assert_eq!( + out.0.len(), + 3, + "envelope + wrapped outline + the withheld-content steer" + ); + match (&out.0[1], &out.0[2]) { + (OutContent::Text(body), OutContent::Text(steer)) => { + assert!( + !body.contains("placeholder"), + "the steer must not be inside the untrusted body: {body}" + ); + assert!(steer.contains("has not exposed"), "{steer}"); + assert!(steer.contains("glass_screenshot"), "{steer}"); + assert!( + !steer.starts_with(crate::untrusted::NOTE) && !steer.contains("⟦untrusted:"), + "glass's own guidance stays outside the untrusted markers: {steer}" + ); + } + other => panic!("unexpected blocks: {other:?}"), + } +} + +#[test] +fn the_withheld_content_steer_sits_between_the_unreadable_one_and_the_document_one() { + // All three can fire on one tree. + let mut tree = unpublished_document_tree(); + tree.unreadable = 1; + tree.unexposed = 1; + let steers = a11y_steers(&tree); + let at = |needle: &str| { + steers + .iter() + .position(|s| s.contains(needle)) + .unwrap_or_else(|| panic!("no steer contains {needle:?}: {steers:?}")) + }; + assert!( + at("could not be read") < at("has not exposed"), + "{steers:?}" + ); + assert!( + at("has not exposed") < at("no readable content"), + "{steers:?}" + ); +} + +#[test] +fn a_snapshot_of_another_app_says_so_in_its_text() { + let mut tree = empty_tree(); + tree.subject = Some(glass_core::Subject { + asked: "com.example.app".into(), + actual: "com.google.android.permissioncontroller".into(), + }); + let steers = a11y_steers(&tree); + assert!( + steers + .iter() + .any(|s| s.contains("com.google.android.permissioncontroller")), + "the agent is told which app the ids address: {steers:?}" + ); +} + +#[test] +fn a11y_snapshot_unsupported_message() { + let mut g = glass_with(FakePlatform::new(40, 30)); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let err = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap_err(); + assert!(err.contains("not supported"), "msg: {err}"); +} + +#[test] +fn set_value_tool_ok_and_errors() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let out = set_value( + &mut g, + &SetValueArgs { + id: 1, + text: "hello".into(), + return_: None, + }, + ) + .unwrap(); + let v = assert_envelope(&out, "glass_set_value"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + // unknown id surfaces the actionable message + let err = set_value( + &mut g, + &SetValueArgs { + id: 99, + text: "x".into(), + return_: None, + }, + ) + .unwrap_err(); + assert!(err.contains("not in the current snapshot"), "msg: {err}"); +} + +#[test] +fn set_value_tool_rejects_uneditable_and_stale() { + let spec = AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }; + // Backend says the element isn't editable: the tool must surface an error, + // never the "set value" confirmation (a silent successful-looking no-op is + // the worst failure for an agent that then asserts "value set"). + let mut g = glass_with_a11y_outcome( + FakePlatform::new(100, 100), + fake_tree(), + SetOutcome::NotEditable, + ); + g.start(&spec).unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let err = set_value( + &mut g, + &SetValueArgs { + id: 1, + text: "x".into(), + return_: None, + }, + ) + .unwrap_err(); + assert!(err.contains("not editable"), "msg: {err}"); + + // Element changed since the snapshot: same contract — error, not success. + let mut g = glass_with_a11y_outcome( + FakePlatform::new(100, 100), + fake_tree(), + SetOutcome::Changed, + ); + g.start(&spec).unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let err = set_value( + &mut g, + &SetValueArgs { + id: 1, + text: "x".into(), + return_: None, + }, + ) + .unwrap_err(); + assert!(err.contains("changed since the snapshot"), "msg: {err}"); +} + +#[test] +fn click_element_tool_ok_and_errors() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert!( + click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None + } + ) + .is_ok() + ); + let err = click_element( + &mut g, + &ClickElementArgs { + id: 99, + return_: None, + }, + ) + .unwrap_err(); + assert!(err.contains("not in the current snapshot"), "msg: {err}"); +} + +#[test] +fn a11y_marks_returns_image_and_legend() { + use glass_core::Frame; + let platform = + FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let mut g = glass_with_a11y(platform, fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_marks(&mut g).unwrap(); + assert!( + matches!(out.0[0], OutContent::Image(_)), + "first item is the image" + ); + let OutContent::Text(t) = &out.0[1] else { + panic!("expected envelope text as the second item") + }; + let v: serde_json::Value = serde_json::from_str(t).expect("envelope must be valid JSON"); + assert_eq!(v["ok"], json!(true), "envelope: {v}"); + assert_eq!(v["tool"], json!("glass_a11y_marks"), "envelope: {v}"); + assert_eq!(v["result"]["count"], json!(1), "envelope: {v}"); + match &out.0[2] { + OutContent::Text(t) => assert!(t.contains("#1 Button \"Save\""), "legend: {t}"), + _ => panic!("expected legend text"), + } +} + +#[test] +fn a11y_marks_legend_spells_a_description_apart_from_a_name() { + use glass_core::{AxRect, Frame}; + let mut tree = fake_tree(); + let mut icon = tree.root.children[0].clone(); + icon.name = None; + icon.description = Some("Bold".into()); + icon.bounds = Some(AxRect { + x: 40, + y: 10, + width: 20, + height: 20, + }); + tree.root.children.push(icon); + let platform = + FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let mut g = glass_with_a11y(platform, tree); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_marks(&mut g).unwrap(); + let OutContent::Text(legend) = &out.0[2] else { + panic!("expected legend text") + }; + // A real name rides in the quoted slot a `name:` selector matches; a description + // rides in `desc="…"`, exactly as the outline spells it. + assert!(legend.contains("#1 Button \"Save\""), "legend: {legend}"); + assert!( + legend.contains("#2 Button desc=\"Bold\""), + "legend: {legend}" + ); + assert!( + !legend.contains("Button \"Bold\""), + "a description must never render as a name: {legend}" + ); +} + +#[test] +fn a11y_marks_legend_untrusted_wrapped_and_image_note_present() { + use glass_core::Frame; + let platform = + FakePlatform::new(100, 100).with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let mut g = glass_with_a11y(platform, fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_marks(&mut g).unwrap(); + // [Image, envelope-Text, legend-Text (untrusted-wrapped), IMAGE_NOTE-Text] + assert!( + out.0.len() >= 4, + "expected [Image, envelope, legend, IMAGE_NOTE], got {} items", + out.0.len() + ); + assert!( + matches!(out.0[0], OutContent::Image(_)), + "image leads: {:?}", + out.0 + ); + // the trusted envelope comes right after the image + match &out.0[1] { + OutContent::Text(t) => { + let v: serde_json::Value = + serde_json::from_str(t).expect("envelope must be valid JSON"); + assert_eq!(v["ok"], json!(true), "envelope: {v}"); + assert_eq!(v["tool"], json!("glass_a11y_marks"), "envelope: {v}"); + } + _ => panic!("expected envelope text as second item"), + } + // legend must be untrusted-wrapped + match &out.0[2] { + OutContent::Text(t) => { + assert!( + t.starts_with(crate::untrusted::NOTE), + "legend must start with NOTE: {t}" + ); + assert!( + t.contains("⟦untrusted:") && t.contains("⟦/untrusted:"), + "legend must be untrusted-wrapped: {t}" + ); + assert!( + t.contains("#1 Button"), + "legend must still contain element: {t}" + ); + } + _ => panic!("expected legend text as third item"), + } + // IMAGE_NOTE must be present + let has_note = out + .0 + .iter() + .any(|c| matches!(c, OutContent::Text(t) if t == crate::untrusted::IMAGE_NOTE)); + assert!(has_note, "IMAGE_NOTE must be present in a11y_marks output"); +} + +pub(crate) fn started_a11y_frames(frames: Vec) -> Glass { + let mut g = glass_with_a11y(FakePlatform::new(100, 100).with_frames(frames), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); // populate last_ax for click_element/set_value + g +} + +#[test] +fn return_none_is_confirmation_only() { + let mut g = started_a11y_frames(vec![]); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None, + }, + ) + .unwrap(); + assert_eq!(out.0.len(), 1, "just the envelope, no siblings"); + let v = assert_envelope(&out, "glass_click_element"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert!(v["observed"].is_null(), "envelope: {v}"); + // The fake's default invoke is unsupported (trait default), so the pointer + // path ran and the fallback reason must be disclosed. + assert_eq!(v["method"], json!("pointer"), "envelope: {v}"); + assert!( + v["native_fallback"].as_str().is_some_and(|s| !s.is_empty()), + "envelope: {v}" + ); + + let out2 = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("none".into()), + }, + ) + .unwrap(); + assert_eq!(out2.0.len(), 1); +} + +#[test] +fn click_element_discloses_native_action_with_no_fallback() { + let mut g = glass_with_a11y_invoke_ok(FakePlatform::new(100, 100), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None, + }, + ) + .unwrap(); + let v = assert_envelope(&out, "glass_click_element"); + assert_eq!(v["method"], json!("native-action"), "envelope: {v}"); + assert!( + v.get("native_fallback").is_none(), + "no fallback key on the native-action path: {v}" + ); +} + +#[test] +fn click_element_names_the_element_it_actuated_instead() { + // The backend resolved the click onto a different element. Without this key the + // result cannot distinguish "clicked the label" from "clicked the row around it". + let mut g = glass_with_a11y_invoke_on_another(FakePlatform::new(100, 100), fake_tree(), 7); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None, + }, + ) + .unwrap(); + let v = assert_envelope(&out, "glass_click_element"); + assert_eq!(v["method"], json!("native-action"), "envelope: {v}"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert_eq!(v["actuated_id"], json!(7), "envelope: {v}"); +} + +#[test] +fn click_element_omits_actuated_id_when_the_target_itself_was_clicked() { + let mut g = glass_with_a11y_invoke_ok(FakePlatform::new(100, 100), fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None, + }, + ) + .unwrap(); + let v = assert_envelope(&out, "glass_click_element"); + assert!( + v.get("actuated_id").is_none(), + "nothing was substituted: {v}" + ); +} + +#[test] +fn return_unknown_errors() { + let mut g = started_a11y_frames(vec![]); + let err = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("wat".into()), + }, + ) + .unwrap_err(); + assert!(err.contains("unknown return"), "msg: {err}"); +} + +#[test] +fn return_snapshot_appends_tree_and_refreshes_cache() { + // vec![] → the snapshot arm's best-effort settle can't capture and is swallowed; the + // tree still folds (the assertions below are unaffected by the settle). + let mut g = started_a11y_frames(vec![]); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 2, + "envelope + exactly one sibling (the a11y outline)" + ); + let v = assert_envelope(&out, "glass_click_element"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert!( + v["observed"].is_null(), + "snapshot doesn't populate `observed`: {v}" + ); + match &out.0[1] { + OutContent::Text(t) => { + assert!( + t.starts_with(crate::untrusted::NOTE), + "must be marked untrusted: {t}" + ); + assert!( + t.contains("#1 Button \"Save\""), + "a11y outline appended: {t}" + ); + } + _ => panic!("expected a11y outline text"), + } + // the snapshot refreshed last_ax -> a follow-up id-based action still resolves + assert!( + click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None + } + ) + .is_ok() + ); +} + +#[test] +fn return_snapshot_discloses_an_unpublished_document_the_same_way_a_snapshot_does() { + // The fold's steer wiring, not `a11y_steers` itself: every fold test until now used a + // tree with nothing to disclose, so dropping the `extend` broke nothing. + let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + // Populates the id cache click_element resolves against, and is the parity tree. + let tree = g.a11y_snapshot(None).unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + let texts: Vec<&String> = out + .0 + .iter() + .filter_map(|c| match c { + OutContent::Text(t) => Some(t), + _ => None, + }) + .collect(); + let steer = texts + .iter() + .find(|t| t.contains("has no readable content")) + .unwrap_or_else(|| panic!("the fold owes the document guidance: {texts:?}")); + assert!( + !steer.starts_with(crate::untrusted::NOTE) && !steer.contains("⟦untrusted:"), + "glass's own guidance, outside the untrusted envelope: {steer}" + ); + assert!(steer.contains("glass_screenshot"), "{steer}"); + // Parity with `a11y_snapshot`: a fifth steer added to one call site and not the + // other fails here too. + for expected in a11y_steers(&tree) { + assert!( + texts.iter().any(|t| **t == expected), + "the fold dropped a steer: {expected}" + ); + } +} + +#[test] +fn return_snapshot_settles_before_folding() { + use glass_core::Frame; + use std::sync::{Arc, Mutex}; + // A settleable frame + a capture counter, wired inline (started_a11y_frames doesn't + // expose a capture log). + let captures = Arc::new(Mutex::new(0usize)); + let platform = FakePlatform::new(100, 100) + .with_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]) + .with_capture_log(captures.clone()); + let mut g = glass_with_a11y(platform, fake_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); // seed last_ax for click_element + let before = *captures.lock().unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + // The a11y outline is still folded (envelope + one untrusted sibling) ... + assert_eq!(out.0.len(), 2, "envelope + a11y outline sibling"); + // ... AND the settle captured frames before the fold. This guards the `wait_stable` + // line: remove it and `captures` stays at `before`. + assert!( + *captures.lock().unwrap() > before, + "return:snapshot must settle (capture frames) before folding" + ); +} + +#[test] +fn return_snapshot_without_frames_still_folds() { + // No frames → the settle's `wait_stable` errors (no scripted frames); the `let _ =` + // swallows it and the tree is still folded. A `?` there would deny the tree. + let mut g = started_a11y_frames(vec![]); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 2, + "tree still folds even when the settle can't run" + ); +} + +#[test] +fn return_settle_appends_settled_text() { + use glass_core::Frame; + // wait_stable needs frames; one solid frame (repeated by the fake) settles. + let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("settle".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 1, + "settle folds into `result.observed`, no extra sibling" + ); + let v = assert_envelope(&out, "glass_click_element"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); +} + +#[test] +fn set_value_return_snapshot() { + let mut g = started_a11y_frames(vec![]); + let out = set_value( + &mut g, + &SetValueArgs { + id: 1, + text: "x".into(), + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + let v = assert_envelope(&out, "glass_set_value"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert!( + matches!(&out.0[1], OutContent::Text(t) if t.starts_with(crate::untrusted::NOTE) && t.contains("#1 Button")), + "outline appended" + ); +} + +#[test] +fn set_value_return_settle_folds_into_observed() { + use glass_core::Frame; + // Mirrors `return_settle_appends_settled_text` for `click_element`: wait_stable + // needs frames; one solid frame (repeated by the fake) settles. + let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let out = set_value( + &mut g, + &SetValueArgs { + id: 1, + text: "x".into(), + return_: Some("settle".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 1, + "settle folds into `result.observed`, no extra sibling" + ); + let v = assert_envelope(&out, "glass_set_value"); + assert_eq!(v["id"], json!(1), "envelope: {v}"); + assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); +} + +#[test] +fn type_return_settle_folds_into_observed() { + use glass_core::Frame; + // Mirrors the click_element/set_value settle observes: wait_stable needs frames; + // one solid frame (repeated by the fake) settles. + let mut g = started_a11y_frames(vec![Frame::solid(100, 100, [0, 0, 0, 255])]); + let out = type_text( + &mut g, + &TypeArgs { + text: "hi".into(), + return_: Some("settle".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 1, + "settle folds into `result.observed`, no extra sibling" + ); + let v = assert_envelope(&out, "glass_type"); + assert_eq!(v["observed"]["settled"], json!(true), "envelope: {v}"); +} + +#[test] +fn type_return_snapshot_appends_outline() { + let mut g = started_a11y_frames(vec![]); + let out = type_text( + &mut g, + &TypeArgs { + text: "x".into(), + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + assert_eq!( + out.0.len(), + 2, + "envelope + exactly one sibling (the a11y outline)" + ); + assert_envelope(&out, "glass_type"); + assert!( + matches!(&out.0[1], OutContent::Text(t) if t.starts_with(crate::untrusted::NOTE) && t.contains("#1 Button")), + "outline appended" + ); + // the snapshot refreshed last_ax -> a follow-up id-based action still resolves + assert!( + click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: None + } + ) + .is_ok() + ); +} + +#[test] +fn type_unknown_return_rejected_before_any_keystroke() { + use std::sync::{Arc, Mutex}; + // A bad `return` value must fail BEFORE the text is injected — an agent that + // retries after this error must not end up with the text typed twice. + let log = Arc::new(Mutex::new(Vec::new())); + let mut g = glass_with(FakePlatform::new(100, 100).with_event_log(log.clone())); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let err = type_text( + &mut g, + &TypeArgs { + text: "x".into(), + return_: Some("bogus".into()), + }, + ) + .unwrap_err(); + assert!(err.contains("unknown return"), "msg: {err}"); + assert!( + log.lock().unwrap().is_empty(), + "no input injected on a rejected `return`: {:?}", + log.lock().unwrap() + ); +} + +#[test] +fn type_observe_failure_says_text_was_typed() { + use std::sync::{Arc, Mutex}; + // A runtime observe failure (here: `snapshot` on a session with no a11y reader) + // happens AFTER the keystrokes landed. The error must say so, or an agent + // retries the whole call and the field ends up with the text twice. + let log = Arc::new(Mutex::new(Vec::new())); + let mut g = glass_with(FakePlatform::new(100, 100).with_event_log(log.clone())); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let err = type_text( + &mut g, + &TypeArgs { + text: "hi".into(), + return_: Some("snapshot".into()), + }, + ) + .unwrap_err(); + assert!(err.contains("text was typed"), "msg: {err}"); + assert_eq!(*log.lock().unwrap(), vec!["type(hi)"], "keystrokes landed"); +} + +#[test] +fn list_and_select_window_tools() { + let mut g = glass_with(FakePlatform::new(320, 240)); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + + let out = list_windows(&mut g).unwrap(); + let v = assert_envelope(&out, "glass_list_windows"); + assert_eq!(v["count"], json!(1), "envelope: {v}"); + let text = match &out.0[1] { + OutContent::Text(t) => t.clone(), + _ => panic!("expected text"), + }; + assert!( + text.starts_with(crate::untrusted::NOTE), + "must be marked untrusted: {text}" + ); + assert!( + text.contains("⟦untrusted:") && text.contains("⟦/untrusted:"), + "enveloped: {text}" + ); + assert!( + text.contains("\"id\":0"), + "json should list window id 0: {text}" + ); + assert!( + text.contains("\"active\":true"), + "json should mark active: {text}" + ); + assert!( + text.contains("\"width\":320"), + "json should include geometry width: {text}" + ); + + let out = select_window(&mut g, &SelectWindowArgs { id: 0 }).unwrap(); + let v = assert_envelope(&out, "glass_select_window"); + assert_eq!(v["width"], json!(320), "envelope: {v}"); + assert_eq!(v["height"], json!(240), "envelope: {v}"); + assert!(select_window(&mut g, &SelectWindowArgs { id: 42 }).is_err()); +} + +#[test] +fn result_envelope_is_leading_and_shaped() { + let out = ToolOutput::result("glass_stop", serde_json::json!({})); + let OutContent::Text(t) = &out.0[0] else { + panic!("expected text") + }; + let v: serde_json::Value = serde_json::from_str(t).unwrap(); + assert_eq!(v["ok"], serde_json::json!(true)); + assert_eq!(v["tool"], serde_json::json!("glass_stop")); + assert_eq!(v["result"], serde_json::json!({})); +} + +#[test] +fn result_with_puts_envelope_first_then_extra() { + let out = ToolOutput::result_with( + "glass_screenshot", + serde_json::json!({ "width": 4, "height": 4 }), + vec![OutContent::Image(vec![1, 2, 3])], + ); + assert!(matches!(out.0[0], OutContent::Text(_)), "envelope leads"); + assert!(matches!(out.0[1], OutContent::Image(_)), "extra follows"); +} diff --git a/crates/glass-mcp/src/tools/testutil.rs b/crates/glass-mcp/src/tools/testutil.rs new file mode 100644 index 00000000..e4d2e9c4 --- /dev/null +++ b/crates/glass-mcp/src/tools/testutil.rs @@ -0,0 +1,429 @@ +//! A scriptable in-memory `Platform` so tool logic can be tested with no X +//! server. Mirrors the one in glass-core's own tests. +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use glass_core::{ + Accessibility, AppSpec, AxContext, AxNode, AxNodeId, AxRect, AxRole, AxStates, AxTarget, + AxTree, Backend, BaselineStore, Frame, Glass, GlassError, KeyEvent, Platform, PlatformFactory, + PointerEvent, Region, Result, Stream, Truncation, TruncationLimit, WindowGeometry, WindowId, + WindowInfo, WindowOp, +}; + +use super::{OutContent, ToolOutput}; + +#[derive(Default)] +pub struct FakePlatform { + pub geometry: WindowGeometry, + pub frames: VecDeque, + pub pending_logs: Vec<(Stream, String)>, + pub pointer_events: Vec, + pub key_events: Vec, + pub started: bool, + pub events: Arc>>, + pub clipboard: String, + /// Count of `capture_frame` calls — lets a test assert a settle actually captured + /// frames (e.g. `return:"snapshot"` settling before it folds the tree). + pub captures: Arc>, + /// Specs `start_app` was handed, in order — the only observer of what the tool layer + /// built from `glass_start`'s arguments. + pub specs: Arc>>, +} + +impl FakePlatform { + pub fn new(width: u32, height: u32) -> Self { + Self { + geometry: WindowGeometry { + x: 0, + y: 0, + width, + height, + }, + ..Default::default() + } + } + pub fn with_frames(mut self, frames: Vec) -> Self { + self.frames = frames.into(); + self + } + pub fn with_logs(mut self, logs: Vec<(Stream, &str)>) -> Self { + self.pending_logs = logs.into_iter().map(|(s, t)| (s, t.to_string())).collect(); + self + } + pub fn with_event_log(mut self, log: Arc>>) -> Self { + self.events = log; + self + } + pub fn with_capture_log(mut self, log: Arc>) -> Self { + self.captures = log; + self + } + pub fn with_spec_log(mut self, log: Arc>>) -> Self { + self.specs = log; + self + } +} + +/// A 4x4 opaque frame, constant everywhere except pixel (3,3), set to `corner` — +/// a stand-in for a perpetually animating rect (a blinking caret, a clock) in +/// `ignore`-masking tests. Mirrors glass-core's own test helper of the same name. +pub fn frame_4x4_corner(corner: [u8; 4]) -> Frame { + let mut px = vec![0u8; 4 * 4 * 4]; + for i in 0..16 { + px[i * 4 + 3] = 255; // alpha + } + let idx = (3 * 4 + 3) * 4; + px[idx..idx + 4].copy_from_slice(&corner); + Frame::new(4, 4, px).expect("4x4 frame is well-formed") +} + +impl Platform for FakePlatform { + fn start_app(&mut self, spec: &AppSpec) -> Result { + self.specs.lock().unwrap().push(spec.clone()); + self.started = true; + Ok(self.geometry.clone()) + } + fn stop_app_by(&mut self, _deadline: glass_core::Deadline) -> Result<()> { + self.started = false; + Ok(()) + } + fn capture_frame(&mut self, region: Option<&Region>) -> Result { + *self.captures.lock().unwrap() += 1; + let frame = match self.frames.pop_front() { + Some(f) => { + if self.frames.is_empty() { + self.frames.push_back(f.clone()); + } + f + } + None => return Err(GlassError::CaptureFailed("no scripted frames".into())), + }; + match region { + Some(r) => frame.crop(r), + None => Ok(frame), + } + } + fn send_pointer(&mut self, e: &PointerEvent) -> Result<()> { + self.events.lock().unwrap().push(match e { + PointerEvent::Click { x, y, .. } => format!("click({x},{y})"), + PointerEvent::Move { x, y } => format!("move({x},{y})"), + PointerEvent::Drag { + from_x, + from_y, + to_x, + to_y, + .. + } => { + format!("drag({from_x},{from_y}->{to_x},{to_y})") + } + PointerEvent::Scroll { x, y, dx, dy, .. } => format!("scroll({x},{y},{dx},{dy})"), + PointerEvent::Gesture { pointers, .. } => format!("gesture({})", pointers.len()), + }); + self.pointer_events.push(e.clone()); + Ok(()) + } + fn send_key(&mut self, e: &KeyEvent) -> Result<()> { + self.events.lock().unwrap().push(match e { + KeyEvent::Text(t) => format!("type({t})"), + KeyEvent::Chord(c) => format!("key({c})"), + }); + self.key_events.push(e.clone()); + Ok(()) + } + fn window(&mut self, op: &WindowOp) -> Result { + match *op { + WindowOp::Resize { width, height } => { + self.geometry.width = width; + self.geometry.height = height; + } + WindowOp::Move { x, y } => { + self.geometry.x = x; + self.geometry.y = y; + } + WindowOp::Focus | WindowOp::Geometry => {} + } + Ok(self.geometry.clone()) + } + fn list_windows(&mut self) -> Result> { + Ok(vec![WindowInfo { + id: WindowId(0), + title: Some("fake".into()), + class: None, + geometry: self.geometry.clone(), + active: true, + }]) + } + fn select_window(&mut self, id: WindowId) -> Result { + if id == WindowId(0) { + Ok(self.geometry.clone()) + } else { + Err(GlassError::WindowNotFound) + } + } + fn drain_logs(&mut self) -> Vec<(Stream, String)> { + std::mem::take(&mut self.pending_logs) + } + fn get_clipboard(&mut self) -> Result { + Ok(self.clipboard.clone()) + } + fn set_clipboard(&mut self, text: &str) -> Result<()> { + self.clipboard = text.to_string(); + Ok(()) + } +} + +/// Build a `Glass` over a `FakePlatform` with a throwaway baseline dir. +pub fn glass_with(platform: FakePlatform) -> Glass { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("baselines"); + std::mem::forget(dir); // keep the dir alive for the test + // Factory yields the pre-scripted platform once. + let mut held: Option> = Some(Box::new(platform)); + let factory: PlatformFactory = Box::new(move |_backend| { + let platform = held + .take() + .ok_or_else(|| GlassError::Backend("test factory called twice".into()))?; + Ok(Backend::display_only(platform)) + }); + Glass::new(factory, "x11".into(), BaselineStore::new(root), 100) +} + +/// What `FakeAccessibility::set_value` should do — lets a test model the +/// backend rejecting a write (element not editable, or changed since the +/// snapshot) so the tool layer's error propagation can be exercised. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum SetOutcome { + #[default] + Ok, + NotEditable, + Changed, +} + +/// What `FakeAccessibility::invoke` should do. Default mirrors the trait's own +/// default (unsupported) — a backend that never implemented the native action, +/// so `click_element` falls back to the pointer path unless a test opts into +/// [`InvokeOutcome::Ok`]. +#[derive(Clone, Copy, Default, PartialEq)] +pub enum InvokeOutcome { + #[default] + Unsupported, + Ok, + /// The native action fired on a different element than the one named. + OkOnAnother(u32), +} + +pub struct FakeAccessibility { + pub tree: AxTree, + pub set_log: std::sync::Arc>>, + pub set_outcome: SetOutcome, + pub invoke_outcome: InvokeOutcome, +} + +impl Accessibility for FakeAccessibility { + fn snapshot(&mut self, _ctx: &AxContext) -> Result { + Ok(self.tree.clone()) + } + fn set_value(&mut self, _ctx: &AxContext, target: &AxTarget, text: &str) -> Result<()> { + match self.set_outcome { + SetOutcome::NotEditable => { + return Err(GlassError::AxElementNotEditable(target.id.0)); + } + SetOutcome::Changed => return Err(GlassError::AxElementChanged(target.id.0)), + SetOutcome::Ok => {} + } + self.set_log + .lock() + .unwrap() + .push((target.clone(), text.to_string())); + Ok(()) + } + fn invoke(&mut self, _ctx: &AxContext, _target: &AxTarget) -> Result> { + match self.invoke_outcome { + InvokeOutcome::Unsupported => Err(GlassError::AxUnsupported), + InvokeOutcome::Ok => Ok(None), + InvokeOutcome::OkOnAnother(id) => Ok(Some(AxNodeId(id))), + } + } +} + +/// A Window #0 with a Button "Save" child at (10,10 20x20). +pub fn fake_tree() -> AxTree { + let button = AxNode { + id: AxNodeId(0), + role: AxRole::Button, + raw_role: "push button".into(), + name: Some("Save".into()), + description: None, + value: None, + states: AxStates { + focusable: true, + enabled: true, + ..Default::default() + }, + bounds: Some(AxRect { + x: 10, + y: 10, + width: 20, + height: 20, + }), + children: vec![], + }; + let root = AxNode { + id: AxNodeId(0), + role: AxRole::Window, + raw_role: "frame".into(), + name: Some("Win".into()), + description: None, + value: None, + states: AxStates::default(), + bounds: Some(AxRect { + x: 0, + y: 0, + width: 100, + height: 100, + }), + children: vec![button], + }; + AxTree::new(root) +} + +/// A window root with no child elements — the "app publishes no usable tree" shape. +pub fn empty_tree() -> AxTree { + let root = AxNode { + id: AxNodeId(0), + role: AxRole::Window, + raw_role: "frame".into(), + name: Some("Win".into()), + description: None, + value: None, + states: AxStates::default(), + bounds: Some(AxRect { + x: 0, + y: 0, + width: 100, + height: 100, + }), + children: vec![], + }; + AxTree::new(root) +} + +/// `fake_tree` with `truncated` set — the "walk stopped early" shape, for testing that +/// the truncation steer surfaces as its own trusted block rather than being baked into +/// the untrusted-wrapped outline. +pub fn truncated_tree() -> AxTree { + let mut t = fake_tree(); + t.truncated = Some(Truncation { + limit: TruncationLimit::Nodes, + limit_value: 1500, + nodes_walked: 1500, + }); + t +} + +/// `fake_tree` with a childless `Document` child — the unpublished-web-content shape. +pub fn unpublished_document_tree() -> AxTree { + let mut t = fake_tree(); + t.root.children.push(AxNode { + id: AxNodeId(0), + role: AxRole::Document, + raw_role: "document web".into(), + name: Some("page".into()), + description: None, + value: None, + states: AxStates::default(), + bounds: Some(AxRect { + x: 0, + y: 40, + width: 100, + height: 60, + }), + children: vec![], + }); + t.assign_ids(); + t +} + +pub fn glass_with_a11y(platform: FakePlatform, tree: AxTree) -> Glass { + glass_with_a11y_outcome(platform, tree, SetOutcome::Ok) +} + +/// Like [`glass_with_a11y`] but with a chosen `set_value` outcome, so a test can +/// drive the not-editable / changed-since-snapshot rejection paths. `invoke` stays +/// at its default (unsupported) — use [`glass_with_a11y_invoke_ok`] for the +/// native-action path. +pub fn glass_with_a11y_outcome( + platform: FakePlatform, + tree: AxTree, + set_outcome: SetOutcome, +) -> Glass { + glass_with_a11y_full(platform, tree, set_outcome, InvokeOutcome::Unsupported) +} + +/// Like [`glass_with_a11y`] but with `invoke` wired to succeed, so a test can drive +/// `click_element`'s native-action path (no pointer event, no fallback disclosed). +pub fn glass_with_a11y_invoke_ok(platform: FakePlatform, tree: AxTree) -> Glass { + glass_with_a11y_full(platform, tree, SetOutcome::Ok, InvokeOutcome::Ok) +} + +/// [`glass_with_a11y_invoke_ok`] for a backend that actuates element `actuated` when +/// asked for another one. +pub fn glass_with_a11y_invoke_on_another( + platform: FakePlatform, + tree: AxTree, + actuated: u32, +) -> Glass { + glass_with_a11y_full( + platform, + tree, + SetOutcome::Ok, + InvokeOutcome::OkOnAnother(actuated), + ) +} + +fn glass_with_a11y_full( + platform: FakePlatform, + tree: AxTree, + set_outcome: SetOutcome, + invoke_outcome: InvokeOutcome, +) -> Glass { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("baselines"); + std::mem::forget(dir); + let mut held: Option = Some(Backend { + platform: Box::new(platform), + accessibility: Some(Box::new(FakeAccessibility { + tree, + set_log: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + set_outcome, + invoke_outcome, + })), + }); + let factory: PlatformFactory = Box::new(move |_backend| { + held.take() + .ok_or_else(|| GlassError::Backend("test factory called twice".into())) + }); + Glass::new(factory, "x11".into(), BaselineStore::new(root), 100) +} + +/// Parse content block `i` as the `{ok,tool,result}` envelope. +pub(crate) fn envelope_at(out: &ToolOutput, i: usize) -> serde_json::Value { + let OutContent::Text(t) = &out.0[i] else { + panic!("expected envelope text at block {i}") + }; + serde_json::from_str(t).expect("envelope must be valid JSON") +} + +/// Assert block 0 is the success envelope for `tool` — and that `tool` is a REGISTERED +/// `#[tool]` name, so a co-typo shared between the tool impl's envelope literal and the +/// test's expected string (both say `"glass_stopp"`) still fails loudly. Returns `result`. +pub(crate) fn assert_envelope(out: &ToolOutput, tool: &str) -> serde_json::Value { + let v = envelope_at(out, 0); + assert_eq!(v["ok"], serde_json::json!(true), "envelope: {v}"); + assert_eq!(v["tool"], serde_json::json!(tool), "envelope: {v}"); + assert!( + crate::server::registered_tools().iter().any(|t| t == tool), + "envelope tool {tool:?} is not a registered #[tool]" + ); + v["result"].clone() +}