use crate::AutomationUsage;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct PageObservation {
pub url: String,
pub title: String,
pub description: String,
pub page_type: String,
#[serde(default)]
pub interactive_elements: Vec<InteractiveElement>,
#[serde(default)]
pub forms: Vec<FormInfo>,
#[serde(default)]
pub navigation: Vec<NavigationOption>,
#[serde(default)]
pub suggested_actions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
#[serde(default)]
pub usage: AutomationUsage,
#[serde(skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_content: Option<String>,
}
impl PageObservation {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
..Default::default()
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn with_page_type(mut self, page_type: impl Into<String>) -> Self {
self.page_type = page_type.into();
self
}
pub fn add_element(mut self, element: InteractiveElement) -> Self {
self.interactive_elements.push(element);
self
}
pub fn add_form(mut self, form: FormInfo) -> Self {
self.forms.push(form);
self
}
pub fn add_navigation(mut self, nav: NavigationOption) -> Self {
self.navigation.push(nav);
self
}
pub fn suggest_action(mut self, action: impl Into<String>) -> Self {
self.suggested_actions.push(action.into());
self
}
pub fn with_screenshot(mut self, screenshot: impl Into<String>) -> Self {
self.screenshot = Some(screenshot.into());
self
}
pub fn with_usage(mut self, usage: AutomationUsage) -> Self {
self.usage = usage;
self
}
pub fn find_element(&self, selector: &str) -> Option<&InteractiveElement> {
self.interactive_elements
.iter()
.find(|e| e.selector == selector)
}
pub fn elements_by_type(&self, element_type: &str) -> Vec<&InteractiveElement> {
self.interactive_elements
.iter()
.filter(|e| e.element_type == element_type)
.collect()
}
pub fn clickable_elements(&self) -> Vec<&InteractiveElement> {
self.interactive_elements
.iter()
.filter(|e| {
e.visible && e.enabled && (e.element_type == "button" || e.element_type == "link")
})
.collect()
}
pub fn input_elements(&self) -> Vec<&InteractiveElement> {
self.interactive_elements
.iter()
.filter(|e| e.element_type.starts_with("input") || e.element_type == "textarea")
.collect()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct InteractiveElement {
pub selector: String,
pub element_type: String,
pub text: String,
pub description: String,
pub visible: bool,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub classes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aria_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bounds: Option<[f64; 4]>,
}
impl InteractiveElement {
pub fn new(selector: impl Into<String>, element_type: impl Into<String>) -> Self {
Self {
selector: selector.into(),
element_type: element_type.into(),
visible: true,
enabled: true,
..Default::default()
}
}
pub fn button(selector: impl Into<String>, text: impl Into<String>) -> Self {
Self {
selector: selector.into(),
element_type: "button".to_string(),
text: text.into(),
visible: true,
enabled: true,
..Default::default()
}
}
pub fn input(selector: impl Into<String>, input_type: &str) -> Self {
Self {
selector: selector.into(),
element_type: format!("input:{}", input_type),
visible: true,
enabled: true,
..Default::default()
}
}
pub fn link(selector: impl Into<String>, text: impl Into<String>) -> Self {
Self {
selector: selector.into(),
element_type: "link".to_string(),
text: text.into(),
visible: true,
enabled: true,
..Default::default()
}
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn with_aria_label(mut self, label: impl Into<String>) -> Self {
self.aria_label = Some(label.into());
self
}
pub fn is_actionable(&self) -> bool {
self.visible && self.enabled
}
pub fn describe(&self) -> String {
if !self.description.is_empty() {
return self.description.clone();
}
if !self.text.is_empty() {
return format!("{}: {}", self.element_type, self.text);
}
if let Some(ref label) = self.aria_label {
return format!("{}: {}", self.element_type, label);
}
if let Some(ref placeholder) = self.placeholder {
return format!("{}: {}", self.element_type, placeholder);
}
format!("{} at {}", self.element_type, self.selector)
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct FormInfo {
pub selector: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default)]
pub fields: Vec<FormField>,
pub description: String,
}
impl FormInfo {
pub fn new(selector: impl Into<String>) -> Self {
Self {
selector: selector.into(),
..Default::default()
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn add_field(mut self, field: FormField) -> Self {
self.fields.push(field);
self
}
pub fn required_fields(&self) -> Vec<&FormField> {
self.fields.iter().filter(|f| f.required).collect()
}
pub fn empty_required_fields(&self) -> Vec<&FormField> {
self.fields
.iter()
.filter(|f| f.required && f.value.as_ref().map(|v| v.is_empty()).unwrap_or(true))
.collect()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct FormField {
pub name: String,
pub field_type: String,
pub label: String,
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default)]
pub options: Vec<String>,
}
impl FormField {
pub fn new(name: impl Into<String>, field_type: impl Into<String>) -> Self {
Self {
name: name.into(),
field_type: field_type.into(),
..Default::default()
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(selector.into());
self
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ActResult {
pub success: bool,
pub action_taken: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub action_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub usage: AutomationUsage,
}
impl ActResult {
pub fn success(action_taken: impl Into<String>) -> Self {
Self {
success: true,
action_taken: action_taken.into(),
..Default::default()
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
error: Some(error.into()),
..Default::default()
}
}
pub fn with_action_type(mut self, action_type: impl Into<String>) -> Self {
self.action_type = Some(action_type.into());
self
}
pub fn with_screenshot(mut self, screenshot: impl Into<String>) -> Self {
self.screenshot = Some(screenshot.into());
self
}
pub fn with_usage(mut self, usage: AutomationUsage) -> Self {
self.usage = usage;
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct NavigationOption {
pub text: String,
pub url: String,
pub selector: String,
pub is_current: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
impl NavigationOption {
pub fn new(
text: impl Into<String>,
url: impl Into<String>,
selector: impl Into<String>,
) -> Self {
Self {
text: text.into(),
url: url.into(),
selector: selector.into(),
is_current: false,
category: None,
}
}
pub fn current(mut self) -> Self {
self.is_current = true;
self
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_page_observation() {
let obs = PageObservation::new("https://example.com")
.with_title("Example")
.with_page_type("homepage")
.add_element(InteractiveElement::button("btn.login", "Login"))
.add_element(InteractiveElement::input("input.email", "email"))
.suggest_action("Click login button");
assert_eq!(obs.clickable_elements().len(), 1);
assert_eq!(obs.input_elements().len(), 1);
assert_eq!(obs.suggested_actions.len(), 1);
}
#[test]
fn test_interactive_element() {
let elem = InteractiveElement::button("button.submit", "Submit")
.with_description("Submit the form")
.with_aria_label("Submit form");
assert!(elem.is_actionable());
assert_eq!(elem.describe(), "Submit the form");
let disabled = elem.clone().enabled(false);
assert!(!disabled.is_actionable());
}
#[test]
fn test_form_info() {
let form = FormInfo::new("form#login")
.with_action("/login")
.add_field(
FormField::new("email", "email")
.required()
.with_label("Email"),
)
.add_field(FormField::new("password", "password").required());
assert_eq!(form.required_fields().len(), 2);
assert_eq!(form.empty_required_fields().len(), 2);
}
}