chromewright 0.4.0

Browser automation MCP server via Chrome DevTools Protocol (CDP)
Documentation
use crate::browser::BrowserSession;
pub(crate) use crate::browser::commands::{
    ActionabilityDiagnostics, ActionabilityPredicate, ActionabilityProbeRequest,
    ActionabilityProbeResult,
};
use crate::browser::commands::{BrowserCommand, BrowserCommandResult};
use crate::error::{BrowserError, Result};

#[derive(Debug, Clone)]
pub(crate) struct ActionabilityRequest<'a> {
    pub selector: &'a str,
    pub target_index: Option<usize>,
    pub predicates: &'a [ActionabilityPredicate],
    pub expected_text: Option<&'a str>,
    pub expected_value: Option<&'a str>,
}

#[cfg(test)]
pub(crate) fn build_actionability_probe_js(request: &ActionabilityRequest<'_>) -> String {
    BrowserCommand::ActionabilityProbe(command_request(request)).render_script()
}

pub(crate) fn probe_actionability(
    session: &BrowserSession,
    request: &ActionabilityRequest<'_>,
) -> Result<ActionabilityProbeResult> {
    let result = session
        .execute_command(BrowserCommand::ActionabilityProbe(command_request(request)))
        .map_err(|e| match e {
            BrowserError::EvaluationFailed(reason) => BrowserError::ToolExecutionFailed {
                tool: "actionability".to_string(),
                reason,
            },
            other => other,
        })?;

    let BrowserCommandResult::ActionabilityProbe(probe) = result else {
        return Err(BrowserError::ToolExecutionFailed {
            tool: "actionability".to_string(),
            reason: "Browser command returned an unexpected result for actionability".to_string(),
        });
    };

    validate_probe_payload(request, &probe)?;

    Ok(probe)
}

fn command_request(request: &ActionabilityRequest<'_>) -> ActionabilityProbeRequest {
    ActionabilityProbeRequest {
        selector: request.selector.to_string(),
        target_index: request.target_index,
        predicates: request.predicates.to_vec(),
        expected_text: request.expected_text.map(str::to_string),
        expected_value: request.expected_value.map(str::to_string),
    }
}

fn validate_probe_payload(
    request: &ActionabilityRequest<'_>,
    probe: &ActionabilityProbeResult,
) -> Result<()> {
    if !probe.present {
        return Ok(());
    }

    let missing = request
        .predicates
        .iter()
        .filter_map(|predicate| {
            (probe.predicate(*predicate).is_none()).then_some(predicate.key().to_string())
        })
        .collect::<Vec<_>>();

    if missing.is_empty() {
        return Ok(());
    }

    Err(BrowserError::ToolExecutionFailed {
        tool: "actionability".to_string(),
        reason: format!(
            "Actionability probe returned an incomplete payload for a present target: missing {}",
            missing.join(", ")
        ),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::browser::BrowserSession;
    use crate::browser::backend::{ScriptEvaluation, SessionBackend, TabDescriptor};
    use crate::dom::{DocumentMetadata, DomTree};
    use serde_json::Value;
    use std::time::Duration;

    #[test]
    fn test_build_actionability_probe_js_keeps_wait_predicates_narrow() {
        let present_js = build_actionability_probe_js(&ActionabilityRequest {
            selector: "#node",
            target_index: None,
            predicates: &[ActionabilityPredicate::Present],
            expected_text: None,
            expected_value: None,
        });
        let present_config = extract_embedded_config(&present_js);
        assert_eq!(present_config["predicates"], serde_json::json!(["present"]));
        assert_eq!(present_config["text"], Value::Null);
        assert_eq!(present_config["value"], Value::Null);

        let visible_js = build_actionability_probe_js(&ActionabilityRequest {
            selector: "#node",
            target_index: None,
            predicates: &[ActionabilityPredicate::Visible],
            expected_text: None,
            expected_value: None,
        });
        let visible_config = extract_embedded_config(&visible_js);
        assert_eq!(visible_config["predicates"], serde_json::json!(["visible"]));
        assert_eq!(visible_config["text"], Value::Null);
        assert_eq!(visible_config["value"], Value::Null);

        let text_js = build_actionability_probe_js(&ActionabilityRequest {
            selector: "#node",
            target_index: None,
            predicates: &[ActionabilityPredicate::TextContains],
            expected_text: Some("hello"),
            expected_value: None,
        });
        let text_config = extract_embedded_config(&text_js);
        assert_eq!(
            text_config["predicates"],
            serde_json::json!(["text_contains"])
        );
        assert_eq!(text_config["text"].as_str(), Some("hello"));
        assert_eq!(text_config["value"], Value::Null);

        let value_js = build_actionability_probe_js(&ActionabilityRequest {
            selector: "#node",
            target_index: None,
            predicates: &[ActionabilityPredicate::ValueEquals],
            expected_text: None,
            expected_value: Some("expected"),
        });
        let value_config = extract_embedded_config(&value_js);
        assert_eq!(
            value_config["predicates"],
            serde_json::json!(["value_equals"])
        );
        assert_eq!(value_config["text"], Value::Null);
        assert_eq!(value_config["value"].as_str(), Some("expected"));
    }

    #[test]
    fn test_build_actionability_probe_js_supports_interaction_predicates_and_target_index() {
        let js = build_actionability_probe_js(&ActionabilityRequest {
            selector: "#inside",
            target_index: Some(4),
            predicates: &[
                ActionabilityPredicate::Visible,
                ActionabilityPredicate::Enabled,
                ActionabilityPredicate::Stable,
                ActionabilityPredicate::ReceivesEvents,
                ActionabilityPredicate::InViewport,
                ActionabilityPredicate::UnobscuredCenter,
            ],
            expected_text: None,
            expected_value: None,
        });

        let config = extract_embedded_config(&js);
        assert_eq!(config["selector"].as_str(), Some("#inside"));
        assert_eq!(config["target_index"].as_u64(), Some(4));
        assert!(js.contains("searchActionableIndex"));
        assert!(js.contains("querySelectorAcrossScopes"));
        assert!(js.contains("const match = resolveTargetMatch(config).match;"));
        assert!(js.contains("element.ownerDocument.elementFromPoint"));
        assert!(js.contains("hit_target"));
    }

    #[test]
    fn test_actionability_probe_result_reports_requested_predicates() {
        let result = ActionabilityProbeResult {
            present: true,
            visible: Some(true),
            enabled: Some(false),
            editable: Some(false),
            stable: Some(true),
            receives_events: Some(false),
            in_viewport: Some(true),
            unobscured_center: Some(false),
            text_contains: Some(true),
            value_equals: Some(false),
            frame_depth: Some(1),
            diagnostics: None,
        };

        assert_eq!(
            result.predicate(ActionabilityPredicate::Present),
            Some(true)
        );
        assert_eq!(
            result.predicate(ActionabilityPredicate::Visible),
            Some(true)
        );
        assert_eq!(
            result.predicate(ActionabilityPredicate::Enabled),
            Some(false)
        );
        assert_eq!(result.predicate(ActionabilityPredicate::Stable), Some(true));
        assert_eq!(
            result.predicate(ActionabilityPredicate::ReceivesEvents),
            Some(false)
        );
        assert_eq!(
            result.predicate(ActionabilityPredicate::TextContains),
            Some(true)
        );
    }

    struct StaticActionabilityBackend {
        value: serde_json::Value,
    }

    impl SessionBackend for StaticActionabilityBackend {
        fn navigate(&self, _url: &str) -> Result<()> {
            unreachable!("navigate is not used in this test")
        }

        fn wait_for_navigation(&self) -> Result<()> {
            unreachable!("wait_for_navigation is not used in this test")
        }

        fn wait_for_document_ready_with_timeout(&self, _timeout: Duration) -> Result<()> {
            unreachable!("wait_for_document_ready_with_timeout is not used in this test")
        }

        fn document_metadata(&self) -> Result<DocumentMetadata> {
            unreachable!("document_metadata is not used in this test")
        }

        fn extract_dom(&self) -> Result<DomTree> {
            unreachable!("extract_dom is not used in this test")
        }

        fn extract_dom_with_prefix(&self, _prefix: &str) -> Result<DomTree> {
            unreachable!("extract_dom_with_prefix is not used in this test")
        }

        fn evaluate(&self, _script: &str, _await_promise: bool) -> Result<ScriptEvaluation> {
            unreachable!("actionability tests use browser commands, not raw evaluate")
        }

        fn execute_command(&self, command: BrowserCommand) -> Result<BrowserCommandResult> {
            match command {
                BrowserCommand::ActionabilityProbe(_) => {
                    serde_json::from_value::<ActionabilityProbeResult>(self.value.clone())
                        .map(BrowserCommandResult::ActionabilityProbe)
                        .map_err(BrowserError::from)
                }
                _ => unreachable!("only actionability commands are used in this test"),
            }
        }

        fn capture_screenshot(&self, _full_page: bool) -> Result<Vec<u8>> {
            unreachable!("capture_screenshot is not used in this test")
        }

        fn press_key(&self, _key: &str) -> Result<()> {
            unreachable!("press_key is not used in this test")
        }

        fn list_tabs(&self) -> Result<Vec<TabDescriptor>> {
            Ok(vec![TabDescriptor {
                id: "tab-1".to_string(),
                title: "Test Tab".to_string(),
                url: "about:blank".to_string(),
            }])
        }

        fn active_tab(&self) -> Result<TabDescriptor> {
            unreachable!("active_tab is not used in this test")
        }

        fn open_tab(&self, _url: &str) -> Result<TabDescriptor> {
            unreachable!("open_tab is not used in this test")
        }

        fn activate_tab(&self, _tab_id: &str) -> Result<()> {
            unreachable!("activate_tab is not used in this test")
        }

        fn close_tab(&self, _tab_id: &str, _with_unload: bool) -> Result<()> {
            unreachable!("close_tab is not used in this test")
        }

        fn close(&self) -> Result<()> {
            unreachable!("close is not used in this test")
        }
    }

    #[test]
    fn test_probe_actionability_rejects_incomplete_present_target_payloads() {
        let session = BrowserSession::with_test_backend(StaticActionabilityBackend {
            value: serde_json::json!({
                "present": true,
                "frame_depth": 0,
            }),
        });

        let err = probe_actionability(
            &session,
            &ActionabilityRequest {
                selector: "#save",
                target_index: None,
                predicates: &[ActionabilityPredicate::Visible],
                expected_text: None,
                expected_value: None,
            },
        )
        .expect_err("missing requested predicate should fail");

        match err {
            BrowserError::ToolExecutionFailed { tool, reason } => {
                assert_eq!(tool, "actionability");
                assert!(reason.contains("incomplete payload"));
                assert!(reason.contains("visible"));
            }
            other => panic!("unexpected actionability error: {other:?}"),
        }
    }

    #[test]
    fn test_probe_actionability_allows_missing_requested_predicates_for_detached_targets() {
        let session = BrowserSession::with_test_backend(StaticActionabilityBackend {
            value: serde_json::json!({
                "present": false,
                "frame_depth": 0,
            }),
        });

        let probe = probe_actionability(
            &session,
            &ActionabilityRequest {
                selector: "#save",
                target_index: None,
                predicates: &[ActionabilityPredicate::Visible],
                expected_text: None,
                expected_value: None,
            },
        )
        .expect("detached targets may omit requested predicate fields");

        assert!(!probe.present);
        assert_eq!(probe.visible, None);
    }

    fn extract_embedded_config(js: &str) -> Value {
        let marker = "const config = ";
        let start = js
            .find(marker)
            .map(|index| index + marker.len())
            .expect("config marker should exist");
        let end = js[start..]
            .find(';')
            .map(|offset| start + offset)
            .expect("config assignment should end with a semicolon");
        serde_json::from_str(&js[start..end]).expect("embedded config should be valid JSON")
    }
}