ferroui-inspect 0.4.0

Opt-in application inspection core for FerroUI, shared by its MCP server and its Chrome DevTools Protocol endpoint
Documentation
use super::*;
use ferroui_base::threading::{CancellationToken, Dispatcher};
use ferroui_controls::{AppBuilder, Application, Border, Button, StackPanel, TextBox, Window};
use ferroui_headless::{
    FerroHeadlessPlatformExtensions, FerroHeadlessPlatformOptions, HeadlessUnitTestSession,
    HeadlessWindowExtensions,
};
use ferroui_skia::SkiaApplicationExtensions;
use ferroui_themes_simple::SimpleTheme;
use std::cell::Cell;

fn app() -> AppBuilder {
    AppBuilder::configure::<Application>()
        .use_skia()
        .use_headless(FerroHeadlessPlatformOptions {
            use_headless_drawing: false,
            ..Default::default()
        })
        .after_setup(|b| {
            b.instance()
                .unwrap()
                .styles()
                .add(SimpleTheme::new().as_style());
        })
}
fn run(f: impl FnOnce() + Send + 'static) {
    let session = HeadlessUnitTestSession::start_new(app);
    session
        .dispatch(f, CancellationToken::none())
        .wait()
        .unwrap();
    session.dispose();
}
/// An inspector with the permissions of a server, which the tests change.
struct Tester {
    inspector: Inspector,
    permissions: Permissions,
}
impl Tester {
    fn node_id(&mut self, object: &FerroObject) -> u64 {
        self.inspector.node_id(object)
    }
    fn execute(&mut self, name: &str, args: &Value) -> InspectResult {
        self.inspector.execute(name, args, self.permissions)
    }
}
fn fixture() -> (Ref<Window>, Ref<Button>, Ref<TextBox>, Tester) {
    let window = Window::new();
    window.set_width(320.0);
    window.set_height(200.0);
    let panel = StackPanel::new();
    let button = Button::new();
    button.set_name(Some("Action".into()));
    button.set_content(Some(Rc::new("Run".to_owned())));
    let text = TextBox::new();
    text.set_name(Some("Editor".into()));
    panel.children().add(button.clone().upcast::<Control>());
    panel.children().add(text.clone().upcast::<Control>());
    window.set_content(Some(Control::boxed(panel)));
    window.show();
    window.capture_rendered_frame();
    let mut inspector = Inspector::new();
    inspector.register_root(&window);
    let tester = Tester {
        inspector,
        permissions: Permissions {
            allow_input: true,
            allow_property_writes: true,
        },
    };
    (window, button, text, tester)
}
#[test]
fn stable_weak_ids_do_not_keep_controls_alive() {
    run(|| {
        let mut inspector = Inspector::new();
        let node = Border::new();
        let weak = node.downgrade();
        let id = inspector.node_id(&node);
        assert_eq!(id, inspector.node_id(&node));
        drop(node);
        assert!(weak.upgrade().is_none());
        assert!(inspector.resolve(id).is_err());
        let next = Border::new();
        assert_ne!(id, inspector.node_id(&next));
    });
}
#[test]
fn trees_search_and_layout_use_real_controls() {
    run(|| {
        let (window, button, _, mut inspector) = fixture();
        let id = inspector.node_id(&button);
        for tree in ["visual", "logical", "automation"] {
            let snapshot = inspector
                .execute(
                    "get_tree",
                    &json!({"tree":tree,"depth":3,"properties":["Width"]}),
                )
                .unwrap();
            assert_eq!(snapshot.as_array().unwrap().len(), 1);
            assert!(snapshot[0]["child_count"].as_u64().unwrap() > 0);
        }
        let found = inspector
            .execute(
                "find_elements",
                &json!({"selector":"Button#Action","text":"Run"}),
            )
            .unwrap();
        assert_eq!(found[0]["node_id"], id);
        assert!(found[0]["bounds"]["width"].as_f64().unwrap() > 0.0);
        assert_eq!(
            inspector
                .execute("find_elements", &json!({"selector":"Button:disabled"}))
                .unwrap(),
            json!([])
        );
        button.set_is_enabled(false);
        assert_eq!(
            inspector
                .execute("find_elements", &json!({"selector":"Button:disabled"}))
                .unwrap()[0]["node_id"],
            id
        );
        let shallow = inspector.execute("get_tree", &json!({"depth":0})).unwrap();
        assert!(shallow[0].get("children").is_none());
        window.close();
    });
}
#[test]
fn property_permissions_conversion_validation_and_sources() {
    run(|| {
        let (_window, button, _text, mut inspector) = fixture();
        let id = inspector.node_id(&button);
        inspector.permissions.allow_property_writes = false;
        assert!(inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Width","value":75})
            )
            .is_err());
        inspector.permissions.allow_property_writes = true;
        let value = inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Width","value":75}),
            )
            .unwrap();
        assert_eq!(button.width(), 75.0);
        assert_eq!(value[0]["priority"], "LocalValue");
        assert!(inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Width","value":-1})
            )
            .is_err());
        assert!(inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Bounds","value":"0,0,1,1"})
            )
            .is_err());
        inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Width","clear":true}),
            )
            .unwrap();
        assert!(button.width().is_nan());
        assert!(inspector
            .execute(
                "set_property",
                &json!({"node_id":id,"property":"Name","value":"Renamed"})
            )
            .is_err());
        let editor = inspector.node_id(&_text);
        inspector
            .execute(
                "set_property",
                &json!({"node_id":editor,"property":"Text","value":"value"}),
            )
            .unwrap();
        assert_eq!(_text.text().as_deref(), Some("value"));
        inspector
            .execute(
                "set_property",
                &json!({"node_id":editor,"property":"Text","value":null}),
            )
            .unwrap();
        assert!(_text.text().is_none());
    });
}
#[test]
fn raw_click_keyboard_text_and_automation_drive_the_ui() {
    run(|| {
        let (window, button, text, mut inspector) = fixture();
        let id = inspector.node_id(&button);
        let editor = inspector.node_id(&text);
        let clicks = Rc::new(Cell::new(0));
        let count = clicks.clone();
        let _subscription = button.click(move |_, _| count.set(count.get() + 1));
        inspector.permissions.allow_input = false;
        assert!(inspector.execute("click", &json!({"node_id":id})).is_err());
        assert_eq!(clicks.get(), 0);
        inspector.permissions.allow_input = true;
        inspector.execute("click", &json!({"node_id":id})).unwrap();
        assert_eq!(clicks.get(), 1);
        inspector
            .execute("invoke_pattern", &json!({"node_id":id,"pattern":"invoke"}))
            .unwrap();
        Dispatcher::ui_thread().run_jobs(None);
        assert_eq!(clicks.get(), 2);
        inspector
            .execute("type_text", &json!({"node_id":editor,"text":"hello"}))
            .unwrap();
        assert_eq!(text.text().as_deref(), Some("hello"));
        inspector
            .execute("press_key", &json!({"node_id":editor,"key":"Ctrl+A"}))
            .unwrap();
        inspector
            .execute("type_text", &json!({"node_id":editor,"text":"replacement"}))
            .unwrap();
        assert_eq!(text.text().as_deref(), Some("replacement"));
        assert!(inspector
            .execute("invoke_pattern", &json!({"node_id":id,"pattern":"toggle"}))
            .is_err());
        window.close();
    });
}
#[test]
fn resources_styles_screenshots_waits_and_stats() {
    run(|| {
        let (window, button, _, mut inspector) = fixture();
        let id = inspector.node_id(&button);
        window.resources().add_value("Greeting", "hello".to_owned());
        let resources = inspector
            .execute(
                "get_resources",
                &json!({"node_id":id,"keys":["Greeting","Missing"]}),
            )
            .unwrap();
        assert_eq!(resources[0]["value"], "hello");
        assert_eq!(resources[1]["found"], false);
        assert!(!inspector
            .execute("get_styles", &json!({"node_id":id}))
            .unwrap()
            .as_array()
            .unwrap()
            .is_empty());
        let image = inspector
            .execute("screenshot", &json!({"scale":0.5}))
            .unwrap();
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(image["data"].as_str().unwrap())
            .unwrap();
        assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
        assert_eq!(image["width"], 160.0);
        assert_eq!(
            inspector
                .execute(
                    "wait_for",
                    &json!({"selector":"#Action","condition":"enabled"})
                )
                .unwrap()["matched"],
            true
        );
        assert_eq!(
            inspector
                .execute("wait_for", &json!({"name":"Missing","condition":"absent"}))
                .unwrap()["matched"],
            true
        );
        assert!(inspector
            .execute("wait_for", &json!({"condition":"property_equals"}))
            .is_err());
        assert_eq!(
            inspector
                .execute("get_frame_stats", &json!({}))
                .unwrap()
                .as_array()
                .unwrap()
                .len(),
            1
        );
        window.close();
    });
}
#[test]
fn schemas_reject_invalid_input_before_mutating() {
    assert_eq!(tools().len(), 16);
    for args in [
        json!({"node_id":0}),
        json!({"node_id":-1}),
        json!({"node_id":"1"}),
        json!({"node_id":1,"typo":true}),
    ] {
        assert!(validate("click", &args).is_err());
    }
    assert!(validate("get_tree", &json!({"depth":100})).is_err());
    assert!(validate("screenshot", &json!({"scale":0})).is_err());
    assert!(validate("unknown", &json!({})).is_err());
}
#[test]
fn one_inspector_serves_the_tools_and_the_targets() {
    run(|| {
        let (window, button, _, _) = fixture();
        let shared = Inspector::shared();
        shared.borrow_mut().register_root(&window);
        let all = Permissions {
            allow_input: true,
            allow_property_writes: true,
        };
        let id = shared.borrow_mut().node_id(&button);
        let mut view = target::Target::new(&window.clone().upcast());
        assert_eq!(view.query(None, "#Action").unwrap(), vec![id]);
        let found = execute("find_elements", &json!({"name":"Action"}), all).unwrap();
        assert_eq!(found[0]["node_id"], id);
        // A highlight is left out of the trees of both. (The first highlight creates the adorner
        // layer of the window, which stays: compare with the highlight shown and hidden.)
        let count = |tree: &Value| -> usize {
            fn walk(node: &Value) -> usize {
                1 + node["children"]
                    .as_array()
                    .map_or(0, |c| c.iter().map(walk).sum())
            }
            tree.as_array().unwrap().iter().map(walk).sum()
        };
        view.highlight(id).unwrap();
        let shown = (
            view.snapshot().unwrap().len(),
            count(&execute("get_tree", &json!({"depth":64}), all).unwrap()),
        );
        view.hide_highlight();
        let hidden = (
            view.snapshot().unwrap().len(),
            count(&execute("get_tree", &json!({"depth":64}), all).unwrap()),
        );
        assert_eq!(shown, hidden);
        // Listing the resources of an element does not give it a dictionary.
        assert!(button.resources_if_created().is_none());
        execute("get_resources", &json!({"node_id":id}), all).unwrap();
        assert!(button.resources_if_created().is_none());
        // Malformed selectors are refused before the framework parses them.
        assert!(execute("find_elements", &json!({"selector":"> Button"}), all).is_err());
        window.close();
    });
}