viewpoint_core/page/locator/select/
mod.rs

1//! Select option methods for Locator.
2//!
3//! This module contains methods for selecting options in `<select>` elements.
4
5use serde::Deserialize;
6use viewpoint_cdp::protocol::dom::{BackendNodeId, ResolveNodeParams, ResolveNodeResult};
7use viewpoint_js::js;
8
9use super::Locator;
10use super::Selector;
11use super::builders::SelectOptionBuilder;
12use super::selector::js_string_literal;
13use crate::error::LocatorError;
14
15impl Locator<'_> {
16    /// Select an option in a `<select>` element by value, label, or index.
17    ///
18    /// Returns a builder that can be configured with additional options.
19    ///
20    /// # Example
21    ///
22    /// ```no_run
23    /// use viewpoint_core::Page;
24    ///
25    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
26    /// // Select by value
27    /// page.locator("select#size").select_option().value("medium").await?;
28    ///
29    /// // Select by visible text (label)
30    /// page.locator("select#size").select_option().label("Medium Size").await?;
31    ///
32    /// // Select multiple options
33    /// page.locator("select#colors").select_option().values(&["red", "blue"]).await?;
34    ///
35    /// // Select without waiting for navigation
36    /// page.locator("select#nav").select_option().value("page2").no_wait_after(true).await?;
37    /// # Ok(())
38    /// # }
39    /// ```
40    pub fn select_option(&self) -> SelectOptionBuilder<'_, '_> {
41        SelectOptionBuilder::new(self)
42    }
43
44    /// Internal method to select a single option (used by builder).
45    pub(crate) async fn select_option_internal(&self, option: &str) -> Result<(), LocatorError> {
46        // Handle Ref selector - lookup in ref map and resolve via CDP
47        if let Selector::Ref(ref_str) = &self.selector {
48            let backend_node_id = self.page.get_backend_node_id_for_ref(ref_str)?;
49            return self
50                .select_option_by_backend_id(backend_node_id, option)
51                .await;
52        }
53
54        // Handle BackendNodeId selector
55        if let Selector::BackendNodeId(backend_node_id) = &self.selector {
56            return self
57                .select_option_by_backend_id(*backend_node_id, option)
58                .await;
59        }
60
61        let js = build_select_option_js(&self.selector.to_js_expression(), option);
62        let result = self.evaluate_js(&js).await?;
63        check_select_result(&result)?;
64        Ok(())
65    }
66
67    /// Internal method to select multiple options (used by builder).
68    pub(crate) async fn select_options_internal(
69        &self,
70        options: &[&str],
71    ) -> Result<(), LocatorError> {
72        // Handle Ref selector - lookup in ref map and resolve via CDP
73        if let Selector::Ref(ref_str) = &self.selector {
74            let backend_node_id = self.page.get_backend_node_id_for_ref(ref_str)?;
75            return self
76                .select_options_by_backend_id(backend_node_id, options)
77                .await;
78        }
79
80        // Handle BackendNodeId selector
81        if let Selector::BackendNodeId(backend_node_id) = &self.selector {
82            return self
83                .select_options_by_backend_id(*backend_node_id, options)
84                .await;
85        }
86
87        let js = build_select_options_js(&self.selector.to_js_expression(), options);
88        let result = self.evaluate_js(&js).await?;
89        check_select_result(&result)?;
90        Ok(())
91    }
92
93    /// Select a single option by backend node ID.
94    async fn select_option_by_backend_id(
95        &self,
96        backend_node_id: BackendNodeId,
97        option: &str,
98    ) -> Result<(), LocatorError> {
99        // Resolve the backend node ID to a RemoteObject
100        let result: ResolveNodeResult = self
101            .page
102            .connection()
103            .send_command(
104                "DOM.resolveNode",
105                Some(ResolveNodeParams {
106                    node_id: None,
107                    backend_node_id: Some(backend_node_id),
108                    object_group: Some("viewpoint-select".to_string()),
109                    execution_context_id: None,
110                }),
111                Some(self.page.session_id()),
112            )
113            .await
114            .map_err(|_| {
115                LocatorError::NotFound(format!(
116                    "Could not resolve backend node ID {backend_node_id}: element may no longer exist"
117                ))
118            })?;
119
120        let object_id = result.object.object_id.ok_or_else(|| {
121            LocatorError::NotFound(format!(
122                "No object ID for backend node ID {backend_node_id}"
123            ))
124        })?;
125
126        // Call select option function on the resolved element
127        #[derive(Debug, Deserialize)]
128        struct CallResult {
129            result: viewpoint_cdp::protocol::runtime::RemoteObject,
130            #[serde(rename = "exceptionDetails")]
131            exception_details: Option<viewpoint_cdp::protocol::runtime::ExceptionDetails>,
132        }
133
134        // Build function declaration for CDP callFunctionOn
135        // Wrapping in parens makes it a valid expression for js! macro parsing
136        let js_fn = js! {
137            (function() {
138                const select = this;
139                if (select.tagName.toLowerCase() !== "select") {
140                    return { success: false, error: "Element is not a select" };
141                }
142
143                const optionValue = #{option};
144
145                // Try to find by value first
146                for (let i = 0; i < select.options.length; i++) {
147                    if (select.options[i].value === optionValue) {
148                        select.selectedIndex = i;
149                        select.dispatchEvent(new Event("change", { bubbles: true }));
150                        return { success: true, selectedIndex: i, selectedValue: select.options[i].value };
151                    }
152                }
153
154                // Try to find by text content
155                for (let i = 0; i < select.options.length; i++) {
156                    if (select.options[i].text === optionValue ||
157                        select.options[i].textContent.trim() === optionValue) {
158                        select.selectedIndex = i;
159                        select.dispatchEvent(new Event("change", { bubbles: true }));
160                        return { success: true, selectedIndex: i, selectedValue: select.options[i].value };
161                    }
162                }
163
164                return { success: false, error: "Option not found: " + optionValue };
165            })
166        };
167        // Strip outer parentheses for CDP (it expects function declaration syntax)
168        let js_fn = js_fn.trim_start_matches('(').trim_end_matches(')');
169
170        let call_result: CallResult = self
171            .page
172            .connection()
173            .send_command(
174                "Runtime.callFunctionOn",
175                Some(serde_json::json!({
176                    "objectId": object_id,
177                    "functionDeclaration": js_fn,
178                    "returnByValue": true
179                })),
180                Some(self.page.session_id()),
181            )
182            .await?;
183
184        // Release the object
185        let _ = self
186            .page
187            .connection()
188            .send_command::<_, serde_json::Value>(
189                "Runtime.releaseObject",
190                Some(serde_json::json!({ "objectId": object_id })),
191                Some(self.page.session_id()),
192            )
193            .await;
194
195        if let Some(exception) = call_result.exception_details {
196            return Err(LocatorError::EvaluationError(exception.text));
197        }
198
199        let value = call_result.result.value.ok_or_else(|| {
200            LocatorError::EvaluationError("No result from select option".to_string())
201        })?;
202
203        check_select_result(&value)?;
204        Ok(())
205    }
206
207    /// Select multiple options by backend node ID.
208    async fn select_options_by_backend_id(
209        &self,
210        backend_node_id: BackendNodeId,
211        options: &[&str],
212    ) -> Result<(), LocatorError> {
213        // Resolve the backend node ID to a RemoteObject
214        let result: ResolveNodeResult = self
215            .page
216            .connection()
217            .send_command(
218                "DOM.resolveNode",
219                Some(ResolveNodeParams {
220                    node_id: None,
221                    backend_node_id: Some(backend_node_id),
222                    object_group: Some("viewpoint-select".to_string()),
223                    execution_context_id: None,
224                }),
225                Some(self.page.session_id()),
226            )
227            .await
228            .map_err(|_| {
229                LocatorError::NotFound(format!(
230                    "Could not resolve backend node ID {backend_node_id}: element may no longer exist"
231                ))
232            })?;
233
234        let object_id = result.object.object_id.ok_or_else(|| {
235            LocatorError::NotFound(format!(
236                "No object ID for backend node ID {backend_node_id}"
237            ))
238        })?;
239
240        // Build options array as JSON
241        let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".to_string());
242
243        // Call select options function on the resolved element
244        #[derive(Debug, Deserialize)]
245        struct CallResult {
246            result: viewpoint_cdp::protocol::runtime::RemoteObject,
247            #[serde(rename = "exceptionDetails")]
248            exception_details: Option<viewpoint_cdp::protocol::runtime::ExceptionDetails>,
249        }
250
251        // Build function declaration for CDP callFunctionOn
252        // Wrapping in parens makes it a valid expression for js! macro parsing
253        let js_fn = js! {
254            (function() {
255                const select = this;
256                if (select.tagName.toLowerCase() !== "select") {
257                    return { success: false, error: "Element is not a select" };
258                }
259
260                const optionValues = @{options_json};
261                const selectedIndices = [];
262
263                if (!select.multiple) {
264                    return { success: false, error: "select_options requires a <select multiple>" };
265                }
266
267                // Deselect all first
268                for (let i = 0; i < select.options.length; i++) {
269                    select.options[i].selected = false;
270                }
271
272                // Select each requested option
273                for (const optionValue of optionValues) {
274                    let found = false;
275
276                    // Try to find by value
277                    for (let i = 0; i < select.options.length; i++) {
278                        if (select.options[i].value === optionValue) {
279                            select.options[i].selected = true;
280                            selectedIndices.push(i);
281                            found = true;
282                            break;
283                        }
284                    }
285
286                    // Try to find by text if not found by value
287                    if (!found) {
288                        for (let i = 0; i < select.options.length; i++) {
289                            if (select.options[i].text === optionValue ||
290                                select.options[i].textContent.trim() === optionValue) {
291                                select.options[i].selected = true;
292                                selectedIndices.push(i);
293                                found = true;
294                                break;
295                            }
296                        }
297                    }
298
299                    if (!found) {
300                        return { success: false, error: "Option not found: " + optionValue };
301                    }
302                }
303
304                select.dispatchEvent(new Event("change", { bubbles: true }));
305                return { success: true, selectedIndices: selectedIndices };
306            })
307        };
308        // Strip outer parentheses for CDP (it expects function declaration syntax)
309        let js_fn = js_fn.trim_start_matches('(').trim_end_matches(')');
310
311        let call_result: CallResult = self
312            .page
313            .connection()
314            .send_command(
315                "Runtime.callFunctionOn",
316                Some(serde_json::json!({
317                    "objectId": object_id,
318                    "functionDeclaration": js_fn,
319                    "returnByValue": true
320                })),
321                Some(self.page.session_id()),
322            )
323            .await?;
324
325        // Release the object
326        let _ = self
327            .page
328            .connection()
329            .send_command::<_, serde_json::Value>(
330                "Runtime.releaseObject",
331                Some(serde_json::json!({ "objectId": object_id })),
332                Some(self.page.session_id()),
333            )
334            .await;
335
336        if let Some(exception) = call_result.exception_details {
337            return Err(LocatorError::EvaluationError(exception.text));
338        }
339
340        let value = call_result.result.value.ok_or_else(|| {
341            LocatorError::EvaluationError("No result from select options".to_string())
342        })?;
343
344        check_select_result(&value)?;
345        Ok(())
346    }
347}
348
349/// Build JavaScript for selecting a single option.
350fn build_select_option_js(selector_expr: &str, option: &str) -> String {
351    format!(
352        r"(function() {{
353            const elements = {selector};
354            if (elements.length === 0) return {{ success: false, error: 'Element not found' }};
355            
356            const select = elements[0];
357            if (select.tagName.toLowerCase() !== 'select') {{
358                return {{ success: false, error: 'Element is not a select' }};
359            }}
360            
361            const optionValue = {option};
362            
363            // Try to find by value first
364            for (let i = 0; i < select.options.length; i++) {{
365                if (select.options[i].value === optionValue) {{
366                    select.selectedIndex = i;
367                    select.dispatchEvent(new Event('change', {{ bubbles: true }}));
368                    return {{ success: true, selectedIndex: i, selectedValue: select.options[i].value }};
369                }}
370            }}
371            
372            // Try to find by text content
373            for (let i = 0; i < select.options.length; i++) {{
374                if (select.options[i].text === optionValue || 
375                    select.options[i].textContent.trim() === optionValue) {{
376                    select.selectedIndex = i;
377                    select.dispatchEvent(new Event('change', {{ bubbles: true }}));
378                    return {{ success: true, selectedIndex: i, selectedValue: select.options[i].value }};
379                }}
380            }}
381            
382            return {{ success: false, error: 'Option not found: ' + optionValue }};
383        }})()",
384        selector = selector_expr,
385        option = js_string_literal(option)
386    )
387}
388
389/// Build JavaScript for selecting multiple options.
390fn build_select_options_js(selector_expr: &str, options: &[&str]) -> String {
391    let options_js: Vec<String> = options.iter().map(|o| js_string_literal(o)).collect();
392    let options_array = format!("[{}]", options_js.join(", "));
393
394    format!(
395        r"(function() {{
396            const elements = {selector_expr};
397            if (elements.length === 0) return {{ success: false, error: 'Element not found' }};
398            
399            const select = elements[0];
400            if (select.tagName.toLowerCase() !== 'select') {{
401                return {{ success: false, error: 'Element is not a select' }};
402            }}
403            
404            const optionValues = {options_array};
405            const selectedIndices = [];
406            
407            // Clear current selection if not multiple
408            if (!select.multiple) {{
409                return {{ success: false, error: 'select_options requires a <select multiple>' }};
410            }}
411            
412            // Deselect all first
413            for (let i = 0; i < select.options.length; i++) {{
414                select.options[i].selected = false;
415            }}
416            
417            // Select each requested option
418            for (const optionValue of optionValues) {{
419                let found = false;
420                
421                // Try to find by value
422                for (let i = 0; i < select.options.length; i++) {{
423                    if (select.options[i].value === optionValue) {{
424                        select.options[i].selected = true;
425                        selectedIndices.push(i);
426                        found = true;
427                        break;
428                    }}
429                }}
430                
431                // Try to find by text if not found by value
432                if (!found) {{
433                    for (let i = 0; i < select.options.length; i++) {{
434                        if (select.options[i].text === optionValue || 
435                            select.options[i].textContent.trim() === optionValue) {{
436                            select.options[i].selected = true;
437                            selectedIndices.push(i);
438                            found = true;
439                            break;
440                        }}
441                    }}
442                }}
443                
444                if (!found) {{
445                    return {{ success: false, error: 'Option not found: ' + optionValue }};
446                }}
447            }}
448            
449            select.dispatchEvent(new Event('change', {{ bubbles: true }}));
450            return {{ success: true, selectedIndices: selectedIndices }};
451        }})()"
452    )
453}
454
455/// Check the result of a select operation.
456fn check_select_result(result: &serde_json::Value) -> Result<(), LocatorError> {
457    let success = result
458        .get("success")
459        .and_then(serde_json::Value::as_bool)
460        .unwrap_or(false);
461
462    if !success {
463        let error = result
464            .get("error")
465            .and_then(|v| v.as_str())
466            .unwrap_or("Unknown error");
467        return Err(LocatorError::EvaluationError(error.to_string()));
468    }
469
470    Ok(())
471}