viewpoint_core/page/locator/select/
mod.rs1use 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 pub fn select_option(&self) -> SelectOptionBuilder<'_, '_> {
41 SelectOptionBuilder::new(self)
42 }
43
44 pub(crate) async fn select_option_internal(&self, option: &str) -> Result<(), LocatorError> {
46 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 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 pub(crate) async fn select_options_internal(
69 &self,
70 options: &[&str],
71 ) -> Result<(), LocatorError> {
72 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 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 async fn select_option_by_backend_id(
95 &self,
96 backend_node_id: BackendNodeId,
97 option: &str,
98 ) -> Result<(), LocatorError> {
99 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 #[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 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 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 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 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 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 async fn select_options_by_backend_id(
209 &self,
210 backend_node_id: BackendNodeId,
211 options: &[&str],
212 ) -> Result<(), LocatorError> {
213 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 let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".to_string());
242
243 #[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 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 for (let i = 0; i < select.options.length; i++) {
269 select.options[i].selected = false;
270 }
271
272 for (const optionValue of optionValues) {
274 let found = false;
275
276 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 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 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 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
349fn 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
389fn 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
455fn 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}