Skip to main content

glass/browser/session/
fill.rs

1//! High-level form filling.
2//!
3//! Provides atomically-resolved multi-field form fill via
4//! [`BrowserSession::fill_form`]. Each field is resolved, then
5//! the appropriate action (type, check, uncheck, select, click) is
6//! applied based on the element's accessibility role.
7
8use super::*;
9
10/// Outcome of a high-level form fill operation.
11///
12/// Returned by [`BrowserSession::fill_form`].
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct FillFormOutcome {
15    /// Number of fields successfully filled.
16    pub filled: usize,
17    /// Total number of fields submitted.
18    pub total: usize,
19    /// Per-field result with action taken and status.
20    pub fields: Vec<FillFieldResult>,
21}
22
23/// Result for a single field within a form fill operation.
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct FillFieldResult {
26    /// Locator target that was resolved.
27    pub target: String,
28    /// Action applied: `"type"`, `"select"`, `"check"`, `"uncheck"`, `"click"`.
29    pub action: String,
30    /// Accessible label of the element, if available.
31    pub label: Option<String>,
32    /// Whether the action succeeded.
33    pub success: bool,
34    /// Error message if the action failed.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub error: Option<String>,
37}
38
39/// Maximum fields accepted in a single [`BrowserSession::fill_form`] call.
40const FILL_FORM_MAX_FIELDS: usize = 16;
41
42impl BrowserSession {
43    /// Fill multiple form fields atomically.
44    ///
45    /// First resolves all locators (failing atomically on any resolution
46    /// error), then applies the appropriate action per field based on
47    /// its role: text inputs get typed, checkboxes/radios get
48    /// checked/unchecked, selects/combo boxes get option selection.
49    ///
50    /// Bounded to 16 fields per call.
51    pub async fn fill_form(&self, fields: &[(&str, &str)]) -> BrowserResult<FillFormOutcome> {
52        let total = fields.len();
53        if total > FILL_FORM_MAX_FIELDS {
54            return Err(format!(
55                "fill_form: max {} fields, got {total}",
56                FILL_FORM_MAX_FIELDS
57            )
58            .into());
59        }
60
61        // Phase 1: resolve all locators atomically
62        let mut resolved: Vec<(String, ResolvedElement)> = Vec::with_capacity(total);
63        for (target, _value) in fields {
64            let element = self
65                .resolve_element(target)
66                .await
67                .map_err(|e| format!("fill_form: resolution failed for \"{target}\": {e}"))?;
68            resolved.push(((*target).to_string(), element));
69        }
70
71        // Phase 2: apply actions
72        let mut results = Vec::with_capacity(total);
73        let mut filled = 0usize;
74
75        for ((target, element), (_t, value)) in resolved.iter().zip(fields.iter()) {
76            let (action, success, error) = self.fill_single_field(element, value).await;
77            if success {
78                filled += 1;
79            }
80            results.push(FillFieldResult {
81                target: target.clone(),
82                action,
83                label: Some(element.label.clone()),
84                success,
85                error,
86            });
87        }
88
89        Ok(FillFormOutcome {
90            filled,
91            total,
92            fields: results,
93        })
94    }
95
96    async fn fill_single_field(
97        &self,
98        element: &ResolvedElement,
99        value: &str,
100    ) -> (String, bool, Option<String>) {
101        let role = element.role.as_deref().unwrap_or("").to_lowercase();
102        let input_type = element.input_type.as_deref().unwrap_or("").to_lowercase();
103
104        let Some(ref reference) = element.reference else {
105            return (
106                "none".to_string(),
107                false,
108                Some("element has no reference".to_string()),
109            );
110        };
111
112        if matches!(role.as_str(), "listbox" | "combobox") {
113            match self.select_option(reference, value).await {
114                Ok(_) => ("select".to_string(), true, None),
115                Err(e) => ("select".to_string(), false, Some(e.to_string())),
116            }
117        } else if role == "checkbox" || input_type == "checkbox" {
118            let should_check =
119                !value.is_empty() && value != "false" && value != "0" && value != "off";
120            let (action, result) = if should_check {
121                ("check", self.check(reference).await)
122            } else {
123                ("uncheck", self.uncheck(reference).await)
124            };
125            match result {
126                Ok(_) => (action.to_string(), true, None),
127                Err(e) => (action.to_string(), false, Some(e.to_string())),
128            }
129        } else if role == "radio" || input_type == "radio" {
130            match self.click(reference).await {
131                Ok(_) => ("click".to_string(), true, None),
132                Err(e) => ("click".to_string(), false, Some(e.to_string())),
133            }
134        } else {
135            match self.type_text(value, Some(reference)).await {
136                Ok(_) => ("type".to_string(), true, None),
137                Err(e) => ("type".to_string(), false, Some(e.to_string())),
138            }
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn fill_field_result_success_serializes_without_error() {
149        let result = FillFieldResult {
150            target: "username".to_string(),
151            action: "type".to_string(),
152            label: Some("Username".to_string()),
153            success: true,
154            error: None,
155        };
156        let json = serde_json::to_value(&result).unwrap();
157        assert_eq!(json["target"], "username");
158        assert_eq!(json["action"], "type");
159        assert_eq!(json["label"], "Username");
160        assert_eq!(json["success"], true);
161        assert!(json.get("error").is_none());
162    }
163
164    #[test]
165    fn fill_field_result_failure_includes_error() {
166        let result = FillFieldResult {
167            target: "missing-field".to_string(),
168            action: "none".to_string(),
169            label: None,
170            success: false,
171            error: Some("element has no reference".to_string()),
172        };
173        let json = serde_json::to_value(&result).unwrap();
174        assert_eq!(json["success"], false);
175        assert_eq!(json["error"], "element has no reference");
176        assert!(json["label"].is_null());
177    }
178
179    #[test]
180    fn fill_form_outcome_counts_filled_and_total() {
181        let outcome = FillFormOutcome {
182            filled: 2,
183            total: 3,
184            fields: vec![
185                FillFieldResult {
186                    target: "a".to_string(),
187                    action: "type".to_string(),
188                    label: None,
189                    success: true,
190                    error: None,
191                },
192                FillFieldResult {
193                    target: "b".to_string(),
194                    action: "check".to_string(),
195                    label: None,
196                    success: true,
197                    error: None,
198                },
199                FillFieldResult {
200                    target: "c".to_string(),
201                    action: "none".to_string(),
202                    label: None,
203                    success: false,
204                    error: Some("not found".to_string()),
205                },
206            ],
207        };
208        let json = serde_json::to_value(&outcome).unwrap();
209        assert_eq!(json["filled"], 2);
210        assert_eq!(json["total"], 3);
211        assert_eq!(json["fields"].as_array().unwrap().len(), 3);
212    }
213
214    #[test]
215    fn fill_form_max_fields_is_16() {
216        assert_eq!(FILL_FORM_MAX_FIELDS, 16);
217    }
218
219    #[test]
220    fn fill_form_max_fields_is_reasonable() {
221        // Must be at least 1 to allow single-field fills
222        const { assert!(FILL_FORM_MAX_FIELDS >= 1) };
223        // Must be at most 64 to prevent unbounded allocation
224        const { assert!(FILL_FORM_MAX_FIELDS <= 64) };
225    }
226
227    #[test]
228    fn fill_form_rejects_exactly_one_over_max() {
229        // Verify the bound check: exactly 17 fields exceeds 16-field cap
230        const { assert!(FILL_FORM_MAX_FIELDS + 1 > FILL_FORM_MAX_FIELDS) };
231        // Boundary: exactly at max should pass the bound check
232        const { assert!(FILL_FORM_MAX_FIELDS == 16) };
233        // The error message uses the constant
234        let err_msg = format!(
235            "fill_form: max {} fields, got {}",
236            FILL_FORM_MAX_FIELDS,
237            FILL_FORM_MAX_FIELDS + 1
238        );
239        assert!(err_msg.contains("max 16 fields"));
240        assert!(err_msg.contains("got 17"));
241    }
242
243    #[test]
244    fn fill_field_result_roundtrip_through_json() {
245        let result = FillFieldResult {
246            target: "email".to_string(),
247            action: "type".to_string(),
248            label: Some("Email Address".to_string()),
249            success: true,
250            error: None,
251        };
252        let json = serde_json::to_string(&result).unwrap();
253        // We can't deserialize back (no Deserialize impl), but verify JSON structure
254        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
255        assert_eq!(parsed["target"], "email");
256        assert_eq!(parsed["success"], true);
257        assert!(parsed.get("error").is_none());
258    }
259}