1use std::fmt::{Display, Formatter};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use crate::proto::browser_session_command::Command as BrowserCommand;
7use crate::proto::browser_session_event::Event as BrowserEvent;
8use crate::proto::engine_service_client::EngineServiceClient;
9use crate::proto::tab_session_command::Command as TabCommand;
10use crate::proto::tab_session_event::Event as TabEvent;
11use crate::proto::{
12 BrowserKind as ProtoBrowserKind, BrowserLaunchedEvent, BrowserSessionCommand,
13 BrowserSessionEvent, ClickElementCommand, CloseBrowserSessionCommand, CloseTabSessionCommand,
14 CommandRetryOptions, CountElementsCommand, ElementCountedEvent, ElementsHighlightedEvent,
15 FillElementCommand, FocusElementCommand, GetInnerTextCommand, GetTextContentCommand,
16 HighlightElementsCommand, HoverElementCommand, LaunchBrowserCommand, NavigateTabCommand,
17 OpenTabCommand, PingRequest, PressKeyCommand, SessionPingCommand, TabSessionCommand,
18 TabSessionEvent, TabSessionPingCommand, WaitForSelectorCommand,
19};
20use serde::Deserialize;
21use tokio::sync::{Mutex as AsyncMutex, mpsc};
22use tokio_stream::wrappers::ReceiverStream;
23use tonic::transport::Channel;
24
25const DEFAULT_SERVER_ADDR: &str = "http://127.0.0.1:50051";
26const SERVER_ADDR_ENV_VAR: &str = "ALLWRIGHT_SERVER_ADDR";
27const CONFIG_FILENAMES: [&str; 6] = [
28 "allwright.config.yaml",
29 "allwright.config.yml",
30 "allwright.config.json",
31 ".allwright/config.yaml",
32 ".allwright/config.yml",
33 ".allwright/config.json",
34];
35
36type Result<T> = std::result::Result<T, Error>;
37
38static RUNTIME: OnceLock<Mutex<Option<Arc<RuntimeClient>>>> = OnceLock::new();
39static SERVER_ADDR_OVERRIDE: OnceLock<Mutex<Option<String>>> = OnceLock::new();
40
41#[derive(Debug)]
42pub struct Error {
43 message: String,
44}
45
46impl Error {
47 fn new(message: impl Into<String>) -> Self {
48 Self {
49 message: message.into(),
50 }
51 }
52}
53
54impl Display for Error {
55 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
56 f.write_str(&self.message)
57 }
58}
59
60impl std::error::Error for Error {}
61
62impl From<tonic::transport::Error> for Error {
63 fn from(value: tonic::transport::Error) -> Self {
64 Self::new(format!("transport error: {value}"))
65 }
66}
67
68impl From<tonic::Status> for Error {
69 fn from(value: tonic::Status) -> Self {
70 Self::new(format!("grpc status error: {value}"))
71 }
72}
73
74#[derive(Debug, Clone, Default, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct LaunchOptions {
77 pub browser_binary: Option<String>,
78 pub timeout_ms: Option<u32>,
79}
80
81#[derive(Debug, Clone, Default, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct RetryConfig {
84 pub timeout_ms: Option<u32>,
85 pub interval_ms: Option<u32>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum BrowserKind {
91 Chromium,
92 Firefox,
93}
94
95#[derive(Debug, Clone, Default, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct ConfigServer {
98 addr: Option<String>,
99}
100
101#[derive(Debug, Clone, Default, Deserialize)]
102#[serde(rename_all = "camelCase")]
103struct ConfigBrowser {
104 name: Option<BrowserKind>,
105 binary: Option<String>,
106 launch_options: Option<LaunchOptions>,
107}
108
109#[derive(Debug, Clone, Default, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct SuiteConfig {
112 server: Option<ConfigServer>,
113 browser: Option<ConfigBrowser>,
114 expect: Option<RetryConfig>,
115}
116
117#[derive(Debug, Clone, Default, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct AllwrightConfig {
120 schema_version: Option<u32>,
121 server: Option<ConfigServer>,
122 browser: Option<ConfigBrowser>,
123 expect: Option<RetryConfig>,
124 suites: Option<std::collections::BTreeMap<String, SuiteConfig>>,
125}
126
127#[derive(Debug, Clone)]
128pub struct ResolvedConfig {
129 pub config_file_path: Option<PathBuf>,
130 pub suite_name: Option<String>,
131 pub server_addr: Option<String>,
132 pub browser_name: BrowserKind,
133 pub browser_binary: Option<String>,
134 pub launch_options: LaunchOptions,
135 pub expect: RetryConfig,
136}
137
138#[derive(Debug, Clone, Default)]
139pub struct ResolveConfigOptions {
140 pub cwd: Option<PathBuf>,
141 pub config_file: Option<PathBuf>,
142 pub suite: Option<String>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub struct BrowserType {
147 browser_kind: BrowserKind,
148}
149
150#[derive(Debug, Clone, Default)]
151pub struct CommandOptions {
152 pub timeout_ms: Option<u32>,
153}
154
155#[derive(Debug, Clone)]
156pub struct NavigateResult {
157 pub url: String,
158 pub note: String,
159 pub bidi_session_id: String,
160 pub mapper_target_id: String,
161 pub mapper_session_id: String,
162 pub package_version: String,
163}
164
165#[derive(Debug, Clone)]
166pub struct ClickResult {
167 pub selector: String,
168 pub note: String,
169 pub bidi_session_id: String,
170}
171
172#[derive(Debug, Clone)]
173pub struct CountResult {
174 pub selector: String,
175 pub count: u32,
176 pub note: String,
177}
178
179#[derive(Debug, Clone, Default)]
180pub struct HighlightOptions {
181 pub timeout_ms: Option<u32>,
182 pub duration_ms: Option<u32>,
183}
184
185#[derive(Debug, Clone)]
186pub struct HighlightResult {
187 pub selector: String,
188 pub count: u32,
189 pub note: String,
190}
191
192#[derive(Debug, Clone)]
193pub struct ElementResult {
194 pub selector: String,
195 pub note: String,
196}
197
198#[derive(Debug, Clone)]
199pub struct FillResult {
200 pub selector: String,
201 pub value: String,
202 pub note: String,
203}
204
205#[derive(Debug, Clone)]
206pub struct PressResult {
207 pub selector: String,
208 pub key: String,
209 pub note: String,
210}
211
212#[derive(Debug, Clone)]
213pub struct TextResult {
214 pub selector: String,
215 pub text: String,
216 pub note: String,
217}
218
219#[derive(Debug, Clone, Default)]
220pub struct PressOptions {
221 pub timeout_ms: Option<u32>,
222 pub text: Option<String>,
223}
224
225#[derive(Debug, Clone, Default)]
226pub struct WaitForSelectorOptions {
227 pub timeout_ms: Option<u32>,
228 pub visible: Option<bool>,
229}
230
231#[derive(Debug, Clone)]
232pub struct WaitForSelectorResult {
233 pub selector: String,
234 pub visible: bool,
235 pub note: String,
236}
237
238#[derive(Clone)]
239pub struct Browser {
240 inner: Arc<BrowserInner>,
241}
242
243#[derive(Clone)]
244pub struct Tab {
245 inner: Arc<TabInner>,
246}
247
248pub type Page = Tab;
249
250#[derive(Clone)]
251pub struct Locator {
252 page: Tab,
253 selector: String,
254}
255
256#[derive(Clone)]
257struct RuntimeClient {
258 engine: EngineServiceClient<Channel>,
259}
260
261struct BrowserInner {
262 runtime: Arc<RuntimeClient>,
263 state: AsyncMutex<BrowserState>,
264 session_id: String,
265 browser_name: String,
266 launch_note: String,
267 cdp_websocket_url: String,
268 user_data_dir: String,
269 initial_tab: Tab,
270}
271
272struct BrowserState {
273 command_tx: mpsc::Sender<BrowserSessionCommand>,
274 events: tonic::Streaming<BrowserSessionEvent>,
275 closed: bool,
276}
277
278struct TabInner {
279 runtime: Arc<RuntimeClient>,
280 browser_session_id: String,
281 session_id: String,
282 state: AsyncMutex<TabState>,
283}
284
285#[derive(Default)]
286struct TabState {
287 handle: Option<TabHandle>,
288}
289
290struct TabHandle {
291 command_tx: mpsc::Sender<TabSessionCommand>,
292 events: tonic::Streaming<TabSessionEvent>,
293 closed: bool,
294}
295
296pub async fn ping() -> Result<String> {
297 let runtime = get_runtime().await?;
298 let mut engine = runtime.engine.clone();
299 let response = engine.ping(tonic::Request::new(PingRequest {})).await?;
300 Ok(response.into_inner().message)
301}
302
303pub async fn launch_chrome(options: LaunchOptions) -> Result<Browser> {
304 launch_browser(BrowserKind::Chromium, options).await
305}
306
307pub async fn launch_firefox(options: LaunchOptions) -> Result<Browser> {
308 launch_browser(BrowserKind::Firefox, options).await
309}
310
311pub async fn launch_browser(browser_kind: BrowserKind, options: LaunchOptions) -> Result<Browser> {
312 let runtime = get_runtime().await?;
313 let mut engine = runtime.engine.clone();
314 let (command_tx, command_rx) = mpsc::channel(16);
315 let response = engine
316 .browser_session(tonic::Request::new(ReceiverStream::new(command_rx)))
317 .await?;
318 let mut events = response.into_inner();
319
320 command_tx
321 .send(BrowserSessionCommand {
322 command: Some(BrowserCommand::LaunchBrowser(LaunchBrowserCommand {
323 browser_kind: match browser_kind {
324 BrowserKind::Chromium => ProtoBrowserKind::Chromium as i32,
325 BrowserKind::Firefox => ProtoBrowserKind::Firefox as i32,
326 },
327 browser_binary: options.browser_binary,
328 retry_options: command_retry_options(options.timeout_ms),
329 })),
330 })
331 .await
332 .map_err(|_| Error::new("failed to send LaunchBrowserCommand to browser session"))?;
333
334 loop {
335 let event = events
336 .message()
337 .await?
338 .ok_or_else(|| Error::new("browser session closed before launch response"))?;
339
340 match event.event {
341 Some(BrowserEvent::BrowserLaunched(BrowserLaunchedEvent {
342 browser,
343 note,
344 user_data_dir,
345 initial_tab_session_id,
346 ..
347 })) => {
348 let browser_session_id = event.session_id;
349 let initial_tab = Tab {
350 inner: Arc::new(TabInner {
351 runtime: Arc::clone(&runtime),
352 browser_session_id: browser_session_id.clone(),
353 session_id: initial_tab_session_id,
354 state: AsyncMutex::new(TabState::default()),
355 }),
356 };
357 return Ok(Browser {
358 inner: Arc::new(BrowserInner {
359 runtime,
360 state: AsyncMutex::new(BrowserState {
361 command_tx,
362 events,
363 closed: false,
364 }),
365 session_id: browser_session_id,
366 browser_name: browser,
367 launch_note: note,
368 cdp_websocket_url: String::new(),
369 user_data_dir,
370 initial_tab,
371 }),
372 });
373 }
374 Some(BrowserEvent::ChromeLaunched(launched)) => {
375 let browser_session_id = event.session_id;
376 let initial_tab = Tab {
377 inner: Arc::new(TabInner {
378 runtime: Arc::clone(&runtime),
379 browser_session_id: browser_session_id.clone(),
380 session_id: launched.initial_tab_session_id.clone(),
381 state: AsyncMutex::new(TabState::default()),
382 }),
383 };
384 return Ok(Browser {
385 inner: Arc::new(BrowserInner {
386 runtime,
387 state: AsyncMutex::new(BrowserState {
388 command_tx,
389 events,
390 closed: false,
391 }),
392 session_id: browser_session_id,
393 browser_name: launched.browser,
394 launch_note: launched.note,
395 cdp_websocket_url: launched.cdp_websocket_url,
396 user_data_dir: launched.user_data_dir,
397 initial_tab,
398 }),
399 });
400 }
401 Some(BrowserEvent::Error(error)) => {
402 return Err(Error::new(format!(
403 "browser session error during launch: {}",
404 error.message
405 )));
406 }
407 _ => {}
408 }
409 }
410}
411
412pub fn chromium() -> BrowserType {
413 BrowserType {
414 browser_kind: BrowserKind::Chromium,
415 }
416}
417
418pub fn firefox() -> BrowserType {
419 BrowserType {
420 browser_kind: BrowserKind::Firefox,
421 }
422}
423
424pub fn set_server_addr(server_addr: impl Into<String>) -> Result<()> {
425 let normalized = normalize_server_addr(&server_addr.into());
426 let mut override_slot = server_addr_override_slot()
427 .lock()
428 .map_err(|_| Error::new("server address override lock is poisoned"))?;
429 *override_slot = Some(normalized);
430 drop(override_slot);
431
432 let mut runtime = runtime_slot()
433 .lock()
434 .map_err(|_| Error::new("runtime singleton lock is poisoned"))?;
435 *runtime = None;
436 Ok(())
437}
438
439pub async fn shutdown() {
440 if let Ok(mut runtime) = runtime_slot().lock() {
441 *runtime = None;
442 }
443}
444
445pub fn find_config_file(start_dir: impl AsRef<Path>) -> Option<PathBuf> {
446 let mut current_dir = start_dir.as_ref().to_path_buf();
447
448 loop {
449 for filename in CONFIG_FILENAMES {
450 let candidate = current_dir.join(filename);
451 if candidate.is_file() {
452 return Some(candidate);
453 }
454 }
455
456 if !current_dir.pop() {
457 return None;
458 }
459 }
460}
461
462pub fn load_config_file(config_file: impl AsRef<Path>) -> Result<AllwrightConfig> {
463 let resolved = config_file.as_ref().to_path_buf();
464 let raw = fs::read_to_string(&resolved).map_err(|error| {
465 Error::new(format!(
466 "failed to read allwright config {}: {error}",
467 resolved.display()
468 ))
469 })?;
470
471 let extension = resolved
472 .extension()
473 .and_then(|value| value.to_str())
474 .unwrap_or_default()
475 .to_ascii_lowercase();
476
477 let config = match extension.as_str() {
478 "json" => serde_json::from_str::<AllwrightConfig>(&raw).map_err(|error| {
479 Error::new(format!(
480 "failed to parse allwright config {} as JSON: {error}",
481 resolved.display()
482 ))
483 })?,
484 "yaml" | "yml" => serde_yaml::from_str::<AllwrightConfig>(&raw).map_err(|error| {
485 Error::new(format!(
486 "failed to parse allwright config {} as YAML: {error}",
487 resolved.display()
488 ))
489 })?,
490 _ => {
491 return Err(Error::new(format!(
492 "unsupported allwright config file extension .{} for {}",
493 if extension.is_empty() {
494 "<none>"
495 } else {
496 &extension
497 },
498 resolved.display()
499 )));
500 }
501 };
502
503 validate_config_shape(&config, &resolved)?;
504 Ok(config)
505}
506
507pub fn resolve_config(options: ResolveConfigOptions) -> Result<ResolvedConfig> {
508 let cwd = options
509 .cwd
510 .unwrap_or(std::env::current_dir().map_err(|error| {
511 Error::new(format!(
512 "failed to determine current working directory: {error}"
513 ))
514 })?);
515 let config_file_path = match options.config_file {
516 Some(path) => Some(path),
517 None => find_config_file(cwd),
518 };
519 let file_config = match &config_file_path {
520 Some(path) => load_config_file(path)?,
521 None => AllwrightConfig::default(),
522 };
523 let suite_name = options.suite.and_then(|suite| {
524 let trimmed = suite.trim().to_owned();
525 if trimmed.is_empty() {
526 None
527 } else {
528 Some(trimmed)
529 }
530 });
531 let suite_config = match &suite_name {
532 Some(name) => {
533 let suite = file_config
534 .suites
535 .as_ref()
536 .and_then(|suites| suites.get(name))
537 .cloned();
538 if suite.is_none() {
539 return Err(Error::new(format!(
540 "allwright config suite \"{}\" was not found in {}",
541 name,
542 config_file_path
543 .as_ref()
544 .map(|path| path.display().to_string())
545 .unwrap_or_else(|| "the resolved config file".to_string())
546 )));
547 }
548 suite
549 }
550 None => None,
551 };
552
553 let server_addr = suite_config
554 .as_ref()
555 .and_then(|suite| suite.server.as_ref())
556 .and_then(|server| server.addr.clone())
557 .or_else(|| {
558 file_config
559 .server
560 .as_ref()
561 .and_then(|server| server.addr.clone())
562 });
563 let browser_name = suite_config
564 .as_ref()
565 .and_then(|suite| suite.browser.as_ref())
566 .and_then(|browser| browser.name)
567 .or_else(|| {
568 file_config
569 .browser
570 .as_ref()
571 .and_then(|browser| browser.name)
572 })
573 .unwrap_or(BrowserKind::Chromium);
574 let browser_binary = suite_config
575 .as_ref()
576 .and_then(|suite| suite.browser.as_ref())
577 .and_then(|browser| browser.binary.clone())
578 .or_else(|| {
579 file_config
580 .browser
581 .as_ref()
582 .and_then(|browser| browser.binary.clone())
583 });
584 let mut launch_options = merge_launch_options(
585 file_config
586 .browser
587 .as_ref()
588 .and_then(|browser| browser.launch_options.clone()),
589 suite_config
590 .as_ref()
591 .and_then(|suite| suite.browser.as_ref())
592 .and_then(|browser| browser.launch_options.clone()),
593 );
594 if let Some(binary) = &browser_binary {
595 launch_options.browser_binary = Some(binary.clone());
596 }
597 let expect = merge_retry_config(
598 file_config.expect.clone(),
599 suite_config.and_then(|suite| suite.expect),
600 );
601
602 Ok(ResolvedConfig {
603 config_file_path,
604 suite_name,
605 server_addr,
606 browser_name,
607 browser_binary,
608 launch_options,
609 expect,
610 })
611}
612
613pub async fn launch_configured_browser(config: &ResolvedConfig) -> Result<Browser> {
614 if let Some(server_addr) = &config.server_addr {
615 set_server_addr(server_addr.clone())?;
616 }
617 launch_browser(config.browser_name, config.launch_options.clone()).await
618}
619
620impl Browser {
621 pub fn page(&self) -> Page {
622 self.initial_tab()
623 }
624
625 pub fn initial_page(&self) -> Page {
626 self.initial_tab()
627 }
628
629 pub fn session_id(&self) -> &str {
630 &self.inner.session_id
631 }
632
633 pub fn browser_name(&self) -> &str {
634 &self.inner.browser_name
635 }
636
637 pub fn launch_note(&self) -> &str {
638 &self.inner.launch_note
639 }
640
641 pub fn cdp_websocket_url(&self) -> &str {
642 &self.inner.cdp_websocket_url
643 }
644
645 pub fn user_data_dir(&self) -> &str {
646 &self.inner.user_data_dir
647 }
648
649 pub fn initial_tab(&self) -> Tab {
650 self.inner.initial_tab.clone()
651 }
652
653 pub async fn new_tab(&self) -> Result<Tab> {
654 self.new_tab_with_options(CommandOptions::default()).await
655 }
656
657 pub async fn new_page(&self) -> Result<Page> {
658 self.new_tab().await
659 }
660
661 pub async fn new_tab_with_options(&self, options: CommandOptions) -> Result<Tab> {
662 let mut state = self.inner.state.lock().await;
663 if state.closed {
664 return Err(Error::new(format!(
665 "browser session {} is closed",
666 self.inner.session_id
667 )));
668 }
669
670 state
671 .command_tx
672 .send(BrowserSessionCommand {
673 command: Some(BrowserCommand::OpenTab(OpenTabCommand {
674 retry_options: command_retry_options(options.timeout_ms),
675 })),
676 })
677 .await
678 .map_err(|_| Error::new("failed to send OpenTabCommand to browser session"))?;
679
680 loop {
681 let event =
682 state.events.message().await?.ok_or_else(|| {
683 Error::new("browser session closed while waiting for new tab")
684 })?;
685
686 match event.event {
687 Some(BrowserEvent::TabOpened(opened)) => {
688 return Ok(Tab {
689 inner: Arc::new(TabInner {
690 runtime: Arc::clone(&self.inner.runtime),
691 browser_session_id: self.inner.session_id.clone(),
692 session_id: opened.tab_session_id,
693 state: AsyncMutex::new(TabState::default()),
694 }),
695 });
696 }
697 Some(BrowserEvent::Error(error)) => {
698 return Err(Error::new(format!(
699 "browser session error while opening tab: {}",
700 error.message
701 )));
702 }
703 _ => {}
704 }
705 }
706 }
707
708 pub async fn ping(&self, message: impl Into<String>) -> Result<String> {
709 let mut state = self.inner.state.lock().await;
710 if state.closed {
711 return Err(Error::new(format!(
712 "browser session {} is closed",
713 self.inner.session_id
714 )));
715 }
716
717 state
718 .command_tx
719 .send(BrowserSessionCommand {
720 command: Some(BrowserCommand::Ping(SessionPingCommand {
721 message: message.into(),
722 })),
723 })
724 .await
725 .map_err(|_| Error::new("failed to send SessionPingCommand to browser session"))?;
726
727 loop {
728 let event = state
729 .events
730 .message()
731 .await?
732 .ok_or_else(|| Error::new("browser session closed while waiting for pong"))?;
733
734 match event.event {
735 Some(BrowserEvent::Pong(pong)) => return Ok(pong.message),
736 Some(BrowserEvent::Error(error)) => {
737 return Err(Error::new(format!(
738 "browser session error while pinging: {}",
739 error.message
740 )));
741 }
742 _ => {}
743 }
744 }
745 }
746
747 pub async fn close(&self) -> Result<()> {
748 let mut state = self.inner.state.lock().await;
749 if state.closed {
750 return Ok(());
751 }
752
753 state
754 .command_tx
755 .send(BrowserSessionCommand {
756 command: Some(BrowserCommand::Close(CloseBrowserSessionCommand {})),
757 })
758 .await
759 .map_err(|_| Error::new("failed to send CloseBrowserSessionCommand"))?;
760
761 loop {
762 let event =
763 state.events.message().await?.ok_or_else(|| {
764 Error::new("browser session closed before close confirmation")
765 })?;
766
767 match event.event {
768 Some(BrowserEvent::Closed(_)) => {
769 state.closed = true;
770 return Ok(());
771 }
772 Some(BrowserEvent::Error(error)) => {
773 return Err(Error::new(format!(
774 "browser session error while closing: {}",
775 error.message
776 )));
777 }
778 _ => {}
779 }
780 }
781 }
782}
783
784impl Tab {
785 pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
786 Locator {
787 page: self.clone(),
788 selector: css_selector.into(),
789 }
790 }
791
792 pub fn session_id(&self) -> &str {
793 &self.inner.session_id
794 }
795
796 pub async fn goto(&self, url: impl Into<String>) -> Result<NavigateResult> {
797 self.navigate(url).await
798 }
799
800 pub async fn ping(&self, message: impl Into<String>) -> Result<String> {
801 let mut state = self.inner.state.lock().await;
802 let handle = self.ensure_handle(&mut state).await?;
803 if handle.closed {
804 return Err(Error::new(format!(
805 "tab session {} is closed",
806 self.inner.session_id
807 )));
808 }
809
810 handle
811 .command_tx
812 .send(TabSessionCommand {
813 browser_session_id: self.inner.browser_session_id.clone(),
814 tab_session_id: self.inner.session_id.clone(),
815 command: Some(TabCommand::Ping(TabSessionPingCommand {
816 message: message.into(),
817 })),
818 })
819 .await
820 .map_err(|_| Error::new("failed to send TabSessionPingCommand"))?;
821
822 loop {
823 let event = handle
824 .events
825 .message()
826 .await?
827 .ok_or_else(|| Error::new("tab session closed while waiting for pong"))?;
828
829 match event.event {
830 Some(TabEvent::Attached(_)) => {}
831 Some(TabEvent::Pong(pong)) => return Ok(pong.message),
832 Some(TabEvent::Error(error)) => {
833 return Err(Error::new(format!(
834 "tab session error while pinging: {}",
835 error.message
836 )));
837 }
838 Some(TabEvent::Closed(_)) => {
839 handle.closed = true;
840 return Err(Error::new(format!(
841 "tab session {} closed while waiting for pong",
842 self.inner.session_id
843 )));
844 }
845 _ => {}
846 }
847 }
848 }
849
850 pub async fn navigate(&self, url: impl Into<String>) -> Result<NavigateResult> {
851 self.navigate_with_options(url, CommandOptions::default())
852 .await
853 }
854
855 pub async fn navigate_with_options(
856 &self,
857 url: impl Into<String>,
858 options: CommandOptions,
859 ) -> Result<NavigateResult> {
860 let mut state = self.inner.state.lock().await;
861 let handle = self.ensure_handle(&mut state).await?;
862 if handle.closed {
863 return Err(Error::new(format!(
864 "tab session {} is closed",
865 self.inner.session_id
866 )));
867 }
868
869 handle
870 .command_tx
871 .send(TabSessionCommand {
872 browser_session_id: self.inner.browser_session_id.clone(),
873 tab_session_id: self.inner.session_id.clone(),
874 command: Some(TabCommand::Navigate(NavigateTabCommand {
875 url: url.into(),
876 retry_options: command_retry_options(options.timeout_ms),
877 })),
878 })
879 .await
880 .map_err(|_| Error::new("failed to send NavigateTabCommand"))?;
881
882 let mut navigated = None;
883 let mut injection = None;
884
885 loop {
886 let event = handle
887 .events
888 .message()
889 .await?
890 .ok_or_else(|| Error::new("tab session closed while waiting for navigation"))?;
891
892 match event.event {
893 Some(TabEvent::Attached(_)) => {}
894 Some(TabEvent::Navigated(navigated_event)) => {
895 navigated = Some(navigated_event);
896 }
897 Some(TabEvent::ChromiumBidiInjection(injection_event)) => {
898 injection = Some(injection_event);
899 }
900 Some(TabEvent::Error(error)) => {
901 return Err(Error::new(format!(
902 "tab session error while navigating: {}",
903 error.message
904 )));
905 }
906 Some(TabEvent::Closed(_)) => {
907 handle.closed = true;
908 return Err(Error::new(format!(
909 "tab session {} closed while navigating",
910 self.inner.session_id
911 )));
912 }
913 _ => {}
914 }
915
916 if let (Some(navigated_event), Some(injection_event)) =
917 (navigated.take(), injection.take())
918 {
919 return Ok(NavigateResult {
920 url: navigated_event.url,
921 note: navigated_event.note,
922 bidi_session_id: injection_event.bidi_session_id,
923 mapper_target_id: injection_event.mapper_target_id,
924 mapper_session_id: injection_event.mapper_session_id,
925 package_version: injection_event.package_version,
926 });
927 }
928 }
929 }
930
931 pub async fn click(&self, css_selector: impl Into<String>) -> Result<ClickResult> {
932 self.click_with_options(css_selector, CommandOptions::default())
933 .await
934 }
935
936 pub async fn click_with_options(
937 &self,
938 css_selector: impl Into<String>,
939 options: CommandOptions,
940 ) -> Result<ClickResult> {
941 let mut state = self.inner.state.lock().await;
942 let handle = self.ensure_handle(&mut state).await?;
943 if handle.closed {
944 return Err(Error::new(format!(
945 "tab session {} is closed",
946 self.inner.session_id
947 )));
948 }
949
950 handle
951 .command_tx
952 .send(TabSessionCommand {
953 browser_session_id: self.inner.browser_session_id.clone(),
954 tab_session_id: self.inner.session_id.clone(),
955 command: Some(TabCommand::ClickElement(ClickElementCommand {
956 css_selector: css_selector.into(),
957 retry_options: command_retry_options(options.timeout_ms),
958 })),
959 })
960 .await
961 .map_err(|_| Error::new("failed to send ClickElementCommand"))?;
962
963 loop {
964 let event =
965 handle.events.message().await?.ok_or_else(|| {
966 Error::new("tab session closed while waiting for click result")
967 })?;
968
969 match event.event {
970 Some(TabEvent::Attached(_)) => {}
971 Some(TabEvent::ElementClicked(clicked)) => {
972 return Ok(ClickResult {
973 selector: clicked.css_selector,
974 note: clicked.note,
975 bidi_session_id: clicked.bidi_session_id,
976 });
977 }
978 Some(TabEvent::Error(error)) => {
979 return Err(Error::new(format!(
980 "tab session error while clicking: {}",
981 error.message
982 )));
983 }
984 Some(TabEvent::Closed(_)) => {
985 handle.closed = true;
986 return Err(Error::new(format!(
987 "tab session {} closed while waiting for click result",
988 self.inner.session_id
989 )));
990 }
991 _ => {}
992 }
993 }
994 }
995
996 pub async fn count(&self, css_selector: impl Into<String>) -> Result<CountResult> {
997 self.count_with_options(css_selector, CommandOptions::default())
998 .await
999 }
1000
1001 pub async fn count_with_options(
1002 &self,
1003 css_selector: impl Into<String>,
1004 options: CommandOptions,
1005 ) -> Result<CountResult> {
1006 let mut state = self.inner.state.lock().await;
1007 let handle = self.ensure_handle(&mut state).await?;
1008 if handle.closed {
1009 return Err(Error::new(format!(
1010 "tab session {} is closed",
1011 self.inner.session_id
1012 )));
1013 }
1014
1015 handle
1016 .command_tx
1017 .send(TabSessionCommand {
1018 browser_session_id: self.inner.browser_session_id.clone(),
1019 tab_session_id: self.inner.session_id.clone(),
1020 command: Some(TabCommand::CountElements(CountElementsCommand {
1021 css_selector: css_selector.into(),
1022 retry_options: command_retry_options(options.timeout_ms),
1023 })),
1024 })
1025 .await
1026 .map_err(|_| Error::new("failed to send CountElementsCommand"))?;
1027
1028 loop {
1029 let event =
1030 handle.events.message().await?.ok_or_else(|| {
1031 Error::new("tab session closed while waiting for count result")
1032 })?;
1033
1034 match event.event {
1035 Some(TabEvent::Attached(_)) => {}
1036 Some(TabEvent::ElementCounted(counted)) => {
1037 return Ok(count_result_from_event(counted));
1038 }
1039 Some(TabEvent::Error(error)) => {
1040 return Err(Error::new(format!(
1041 "tab session error while counting elements: {}",
1042 error.message
1043 )));
1044 }
1045 Some(TabEvent::Closed(_)) => {
1046 handle.closed = true;
1047 return Err(Error::new(format!(
1048 "tab session {} closed while waiting for count result",
1049 self.inner.session_id
1050 )));
1051 }
1052 _ => {}
1053 }
1054 }
1055 }
1056
1057 pub async fn highlight(&self, css_selector: impl Into<String>) -> Result<HighlightResult> {
1058 self.highlight_with_options(css_selector, HighlightOptions::default())
1059 .await
1060 }
1061
1062 pub async fn highlight_with_options(
1063 &self,
1064 css_selector: impl Into<String>,
1065 options: HighlightOptions,
1066 ) -> Result<HighlightResult> {
1067 let mut state = self.inner.state.lock().await;
1068 let handle = self.ensure_handle(&mut state).await?;
1069 if handle.closed {
1070 return Err(Error::new(format!(
1071 "tab session {} is closed",
1072 self.inner.session_id
1073 )));
1074 }
1075
1076 handle
1077 .command_tx
1078 .send(TabSessionCommand {
1079 browser_session_id: self.inner.browser_session_id.clone(),
1080 tab_session_id: self.inner.session_id.clone(),
1081 command: Some(TabCommand::HighlightElements(HighlightElementsCommand {
1082 css_selector: css_selector.into(),
1083 duration_ms: options.duration_ms,
1084 retry_options: command_retry_options(options.timeout_ms),
1085 })),
1086 })
1087 .await
1088 .map_err(|_| Error::new("failed to send HighlightElementsCommand"))?;
1089
1090 loop {
1091 let event = handle.events.message().await?.ok_or_else(|| {
1092 Error::new("tab session closed while waiting for highlight result")
1093 })?;
1094
1095 match event.event {
1096 Some(TabEvent::Attached(_)) => {}
1097 Some(TabEvent::ElementsHighlighted(highlighted)) => {
1098 return Ok(highlight_result_from_event(highlighted));
1099 }
1100 Some(TabEvent::Error(error)) => {
1101 return Err(Error::new(format!(
1102 "tab session error while highlighting elements: {}",
1103 error.message
1104 )));
1105 }
1106 Some(TabEvent::Closed(_)) => {
1107 handle.closed = true;
1108 return Err(Error::new(format!(
1109 "tab session {} closed while waiting for highlight result",
1110 self.inner.session_id
1111 )));
1112 }
1113 _ => {}
1114 }
1115 }
1116 }
1117
1118 pub async fn focus(&self, css_selector: impl Into<String>) -> Result<ElementResult> {
1119 self.focus_with_options(css_selector, CommandOptions::default())
1120 .await
1121 }
1122
1123 pub async fn focus_with_options(
1124 &self,
1125 css_selector: impl Into<String>,
1126 options: CommandOptions,
1127 ) -> Result<ElementResult> {
1128 let mut state = self.inner.state.lock().await;
1129 let handle = self.ensure_handle(&mut state).await?;
1130 if handle.closed {
1131 return Err(Error::new(format!(
1132 "tab session {} is closed",
1133 self.inner.session_id
1134 )));
1135 }
1136 handle
1137 .command_tx
1138 .send(TabSessionCommand {
1139 browser_session_id: self.inner.browser_session_id.clone(),
1140 tab_session_id: self.inner.session_id.clone(),
1141 command: Some(TabCommand::FocusElement(FocusElementCommand {
1142 css_selector: css_selector.into(),
1143 retry_options: command_retry_options(options.timeout_ms),
1144 })),
1145 })
1146 .await
1147 .map_err(|_| Error::new("failed to send FocusElementCommand"))?;
1148 loop {
1149 let event =
1150 handle.events.message().await?.ok_or_else(|| {
1151 Error::new("tab session closed while waiting for focus result")
1152 })?;
1153 match event.event {
1154 Some(TabEvent::Attached(_)) => {}
1155 Some(TabEvent::ElementFocused(focused)) => {
1156 return Ok(ElementResult {
1157 selector: focused.css_selector,
1158 note: focused.note,
1159 });
1160 }
1161 Some(TabEvent::Error(error)) => {
1162 return Err(Error::new(format!(
1163 "tab session error while focusing: {}",
1164 error.message
1165 )));
1166 }
1167 Some(TabEvent::Closed(_)) => {
1168 handle.closed = true;
1169 return Err(Error::new(format!(
1170 "tab session {} closed while waiting for focus result",
1171 self.inner.session_id
1172 )));
1173 }
1174 _ => {}
1175 }
1176 }
1177 }
1178
1179 pub async fn fill(
1180 &self,
1181 css_selector: impl Into<String>,
1182 value: impl Into<String>,
1183 ) -> Result<FillResult> {
1184 self.fill_with_options(css_selector, value, CommandOptions::default())
1185 .await
1186 }
1187
1188 pub async fn fill_with_options(
1189 &self,
1190 css_selector: impl Into<String>,
1191 value: impl Into<String>,
1192 options: CommandOptions,
1193 ) -> Result<FillResult> {
1194 let mut state = self.inner.state.lock().await;
1195 let handle = self.ensure_handle(&mut state).await?;
1196 if handle.closed {
1197 return Err(Error::new(format!(
1198 "tab session {} is closed",
1199 self.inner.session_id
1200 )));
1201 }
1202 handle
1203 .command_tx
1204 .send(TabSessionCommand {
1205 browser_session_id: self.inner.browser_session_id.clone(),
1206 tab_session_id: self.inner.session_id.clone(),
1207 command: Some(TabCommand::FillElement(FillElementCommand {
1208 css_selector: css_selector.into(),
1209 value: value.into(),
1210 retry_options: command_retry_options(options.timeout_ms),
1211 })),
1212 })
1213 .await
1214 .map_err(|_| Error::new("failed to send FillElementCommand"))?;
1215 loop {
1216 let event =
1217 handle.events.message().await?.ok_or_else(|| {
1218 Error::new("tab session closed while waiting for fill result")
1219 })?;
1220 match event.event {
1221 Some(TabEvent::Attached(_)) => {}
1222 Some(TabEvent::ElementFilled(filled)) => {
1223 return Ok(FillResult {
1224 selector: filled.css_selector,
1225 value: filled.value,
1226 note: filled.note,
1227 });
1228 }
1229 Some(TabEvent::Error(error)) => {
1230 return Err(Error::new(format!(
1231 "tab session error while filling: {}",
1232 error.message
1233 )));
1234 }
1235 Some(TabEvent::Closed(_)) => {
1236 handle.closed = true;
1237 return Err(Error::new(format!(
1238 "tab session {} closed while waiting for fill result",
1239 self.inner.session_id
1240 )));
1241 }
1242 _ => {}
1243 }
1244 }
1245 }
1246
1247 pub async fn hover(&self, css_selector: impl Into<String>) -> Result<ElementResult> {
1248 self.hover_with_options(css_selector, CommandOptions::default())
1249 .await
1250 }
1251
1252 pub async fn hover_with_options(
1253 &self,
1254 css_selector: impl Into<String>,
1255 options: CommandOptions,
1256 ) -> Result<ElementResult> {
1257 let mut state = self.inner.state.lock().await;
1258 let handle = self.ensure_handle(&mut state).await?;
1259 if handle.closed {
1260 return Err(Error::new(format!(
1261 "tab session {} is closed",
1262 self.inner.session_id
1263 )));
1264 }
1265 handle
1266 .command_tx
1267 .send(TabSessionCommand {
1268 browser_session_id: self.inner.browser_session_id.clone(),
1269 tab_session_id: self.inner.session_id.clone(),
1270 command: Some(TabCommand::HoverElement(HoverElementCommand {
1271 css_selector: css_selector.into(),
1272 retry_options: command_retry_options(options.timeout_ms),
1273 })),
1274 })
1275 .await
1276 .map_err(|_| Error::new("failed to send HoverElementCommand"))?;
1277 loop {
1278 let event =
1279 handle.events.message().await?.ok_or_else(|| {
1280 Error::new("tab session closed while waiting for hover result")
1281 })?;
1282 match event.event {
1283 Some(TabEvent::Attached(_)) => {}
1284 Some(TabEvent::ElementHovered(hovered)) => {
1285 return Ok(ElementResult {
1286 selector: hovered.css_selector,
1287 note: hovered.note,
1288 });
1289 }
1290 Some(TabEvent::Error(error)) => {
1291 return Err(Error::new(format!(
1292 "tab session error while hovering: {}",
1293 error.message
1294 )));
1295 }
1296 Some(TabEvent::Closed(_)) => {
1297 handle.closed = true;
1298 return Err(Error::new(format!(
1299 "tab session {} closed while waiting for hover result",
1300 self.inner.session_id
1301 )));
1302 }
1303 _ => {}
1304 }
1305 }
1306 }
1307
1308 pub async fn press(
1309 &self,
1310 css_selector: impl Into<String>,
1311 key: impl Into<String>,
1312 ) -> Result<PressResult> {
1313 self.press_with_options(css_selector, key, PressOptions::default())
1314 .await
1315 }
1316
1317 pub async fn press_with_options(
1318 &self,
1319 css_selector: impl Into<String>,
1320 key: impl Into<String>,
1321 options: PressOptions,
1322 ) -> Result<PressResult> {
1323 let mut state = self.inner.state.lock().await;
1324 let handle = self.ensure_handle(&mut state).await?;
1325 if handle.closed {
1326 return Err(Error::new(format!(
1327 "tab session {} is closed",
1328 self.inner.session_id
1329 )));
1330 }
1331 handle
1332 .command_tx
1333 .send(TabSessionCommand {
1334 browser_session_id: self.inner.browser_session_id.clone(),
1335 tab_session_id: self.inner.session_id.clone(),
1336 command: Some(TabCommand::PressKey(PressKeyCommand {
1337 css_selector: css_selector.into(),
1338 key: key.into(),
1339 text: options.text,
1340 retry_options: command_retry_options(options.timeout_ms),
1341 })),
1342 })
1343 .await
1344 .map_err(|_| Error::new("failed to send PressKeyCommand"))?;
1345 loop {
1346 let event =
1347 handle.events.message().await?.ok_or_else(|| {
1348 Error::new("tab session closed while waiting for press result")
1349 })?;
1350 match event.event {
1351 Some(TabEvent::Attached(_)) => {}
1352 Some(TabEvent::KeyPressed(pressed)) => {
1353 return Ok(PressResult {
1354 selector: pressed.css_selector,
1355 key: pressed.key,
1356 note: pressed.note,
1357 });
1358 }
1359 Some(TabEvent::Error(error)) => {
1360 return Err(Error::new(format!(
1361 "tab session error while pressing key: {}",
1362 error.message
1363 )));
1364 }
1365 Some(TabEvent::Closed(_)) => {
1366 handle.closed = true;
1367 return Err(Error::new(format!(
1368 "tab session {} closed while waiting for press result",
1369 self.inner.session_id
1370 )));
1371 }
1372 _ => {}
1373 }
1374 }
1375 }
1376
1377 pub async fn text_content(&self, css_selector: impl Into<String>) -> Result<TextResult> {
1378 self.text_content_with_options(css_selector, CommandOptions::default())
1379 .await
1380 }
1381
1382 pub async fn text_content_with_options(
1383 &self,
1384 css_selector: impl Into<String>,
1385 options: CommandOptions,
1386 ) -> Result<TextResult> {
1387 self.read_text(css_selector.into(), options, true).await
1388 }
1389
1390 pub async fn inner_text(&self, css_selector: impl Into<String>) -> Result<TextResult> {
1391 self.inner_text_with_options(css_selector, CommandOptions::default())
1392 .await
1393 }
1394
1395 pub async fn inner_text_with_options(
1396 &self,
1397 css_selector: impl Into<String>,
1398 options: CommandOptions,
1399 ) -> Result<TextResult> {
1400 self.read_text(css_selector.into(), options, false).await
1401 }
1402
1403 pub async fn wait_for_selector(
1404 &self,
1405 css_selector: impl Into<String>,
1406 ) -> Result<WaitForSelectorResult> {
1407 self.wait_for_selector_with_options(css_selector, WaitForSelectorOptions::default())
1408 .await
1409 }
1410
1411 pub async fn wait_for_selector_with_options(
1412 &self,
1413 css_selector: impl Into<String>,
1414 options: WaitForSelectorOptions,
1415 ) -> Result<WaitForSelectorResult> {
1416 let mut state = self.inner.state.lock().await;
1417 let handle = self.ensure_handle(&mut state).await?;
1418 if handle.closed {
1419 return Err(Error::new(format!(
1420 "tab session {} is closed",
1421 self.inner.session_id
1422 )));
1423 }
1424 handle
1425 .command_tx
1426 .send(TabSessionCommand {
1427 browser_session_id: self.inner.browser_session_id.clone(),
1428 tab_session_id: self.inner.session_id.clone(),
1429 command: Some(TabCommand::WaitForSelector(WaitForSelectorCommand {
1430 css_selector: css_selector.into(),
1431 visible: options.visible,
1432 retry_options: command_retry_options(options.timeout_ms),
1433 })),
1434 })
1435 .await
1436 .map_err(|_| Error::new("failed to send WaitForSelectorCommand"))?;
1437 loop {
1438 let event = handle
1439 .events
1440 .message()
1441 .await?
1442 .ok_or_else(|| Error::new("tab session closed while waiting for selector"))?;
1443 match event.event {
1444 Some(TabEvent::Attached(_)) => {}
1445 Some(TabEvent::SelectorWaitSatisfied(waited)) => {
1446 return Ok(WaitForSelectorResult {
1447 selector: waited.css_selector,
1448 visible: waited.visible,
1449 note: waited.note,
1450 });
1451 }
1452 Some(TabEvent::Error(error)) => {
1453 return Err(Error::new(format!(
1454 "tab session error while waiting for selector: {}",
1455 error.message
1456 )));
1457 }
1458 Some(TabEvent::Closed(_)) => {
1459 handle.closed = true;
1460 return Err(Error::new(format!(
1461 "tab session {} closed while waiting for selector result",
1462 self.inner.session_id
1463 )));
1464 }
1465 _ => {}
1466 }
1467 }
1468 }
1469
1470 async fn read_text(
1471 &self,
1472 css_selector: String,
1473 options: CommandOptions,
1474 text_content: bool,
1475 ) -> Result<TextResult> {
1476 let mut state = self.inner.state.lock().await;
1477 let handle = self.ensure_handle(&mut state).await?;
1478 if handle.closed {
1479 return Err(Error::new(format!(
1480 "tab session {} is closed",
1481 self.inner.session_id
1482 )));
1483 }
1484 let command = if text_content {
1485 TabCommand::GetTextContent(GetTextContentCommand {
1486 css_selector,
1487 retry_options: command_retry_options(options.timeout_ms),
1488 })
1489 } else {
1490 TabCommand::GetInnerText(GetInnerTextCommand {
1491 css_selector,
1492 retry_options: command_retry_options(options.timeout_ms),
1493 })
1494 };
1495 handle
1496 .command_tx
1497 .send(TabSessionCommand {
1498 browser_session_id: self.inner.browser_session_id.clone(),
1499 tab_session_id: self.inner.session_id.clone(),
1500 command: Some(command),
1501 })
1502 .await
1503 .map_err(|_| Error::new("failed to send text command"))?;
1504 loop {
1505 let event =
1506 handle.events.message().await?.ok_or_else(|| {
1507 Error::new("tab session closed while waiting for text result")
1508 })?;
1509 match event.event {
1510 Some(TabEvent::Attached(_)) => {}
1511 Some(TabEvent::TextContentResolved(text)) => {
1512 return Ok(TextResult {
1513 selector: text.css_selector,
1514 text: text.text,
1515 note: text.note,
1516 });
1517 }
1518 Some(TabEvent::InnerTextResolved(text)) => {
1519 return Ok(TextResult {
1520 selector: text.css_selector,
1521 text: text.text,
1522 note: text.note,
1523 });
1524 }
1525 Some(TabEvent::Error(error)) => {
1526 return Err(Error::new(format!(
1527 "tab session error while reading text: {}",
1528 error.message
1529 )));
1530 }
1531 Some(TabEvent::Closed(_)) => {
1532 handle.closed = true;
1533 return Err(Error::new(format!(
1534 "tab session {} closed while waiting for text result",
1535 self.inner.session_id
1536 )));
1537 }
1538 _ => {}
1539 }
1540 }
1541 }
1542
1543 pub async fn close(&self) -> Result<()> {
1544 let mut state = self.inner.state.lock().await;
1545 let handle = self.ensure_handle(&mut state).await?;
1546 if handle.closed {
1547 return Ok(());
1548 }
1549
1550 handle
1551 .command_tx
1552 .send(TabSessionCommand {
1553 browser_session_id: self.inner.browser_session_id.clone(),
1554 tab_session_id: self.inner.session_id.clone(),
1555 command: Some(TabCommand::Close(CloseTabSessionCommand {})),
1556 })
1557 .await
1558 .map_err(|_| Error::new("failed to send CloseTabSessionCommand"))?;
1559
1560 loop {
1561 let event = handle
1562 .events
1563 .message()
1564 .await?
1565 .ok_or_else(|| Error::new("tab session closed before close confirmation"))?;
1566
1567 match event.event {
1568 Some(TabEvent::Attached(_)) => {}
1569 Some(TabEvent::Closed(_)) => {
1570 handle.closed = true;
1571 return Ok(());
1572 }
1573 Some(TabEvent::Error(error)) => {
1574 return Err(Error::new(format!(
1575 "tab session error while closing: {}",
1576 error.message
1577 )));
1578 }
1579 _ => {}
1580 }
1581 }
1582 }
1583
1584 async fn ensure_handle<'a>(&self, state: &'a mut TabState) -> Result<&'a mut TabHandle> {
1585 if state.handle.is_none() {
1586 let mut engine = self.inner.runtime.engine.clone();
1587 let (command_tx, command_rx) = mpsc::channel(16);
1588 let response = engine
1589 .tab_session(tonic::Request::new(ReceiverStream::new(command_rx)))
1590 .await?;
1591 state.handle = Some(TabHandle {
1592 command_tx,
1593 events: response.into_inner(),
1594 closed: false,
1595 });
1596 }
1597
1598 state
1599 .handle
1600 .as_mut()
1601 .ok_or_else(|| Error::new("tab session handle was not initialized"))
1602 }
1603}
1604
1605impl BrowserType {
1606 pub async fn launch(&self, options: LaunchOptions) -> Result<Browser> {
1607 launch_browser(self.browser_kind, options).await
1608 }
1609}
1610
1611impl Locator {
1612 pub fn page(&self) -> &Page {
1613 &self.page
1614 }
1615
1616 pub fn selector(&self) -> &str {
1617 &self.selector
1618 }
1619
1620 pub fn locator(&self, css_selector: impl Into<String>) -> Locator {
1621 Locator {
1622 page: self.page.clone(),
1623 selector: format!("{} {}", self.selector, css_selector.into()),
1624 }
1625 }
1626
1627 pub async fn click(&self) -> Result<ClickResult> {
1628 self.page.click(self.selector.clone()).await
1629 }
1630
1631 pub async fn count(&self) -> Result<CountResult> {
1632 self.page.count(self.selector.clone()).await
1633 }
1634
1635 pub async fn highlight(&self) -> Result<HighlightResult> {
1636 self.page.highlight(self.selector.clone()).await
1637 }
1638
1639 pub async fn focus(&self) -> Result<ElementResult> {
1640 self.page.focus(self.selector.clone()).await
1641 }
1642
1643 pub async fn fill(&self, value: impl Into<String>) -> Result<FillResult> {
1644 self.page.fill(self.selector.clone(), value.into()).await
1645 }
1646
1647 pub async fn hover(&self) -> Result<ElementResult> {
1648 self.page.hover(self.selector.clone()).await
1649 }
1650
1651 pub async fn press(&self, key: impl Into<String>) -> Result<PressResult> {
1652 self.page.press(self.selector.clone(), key.into()).await
1653 }
1654
1655 pub async fn text_content(&self) -> Result<TextResult> {
1656 self.page.text_content(self.selector.clone()).await
1657 }
1658
1659 pub async fn inner_text(&self) -> Result<TextResult> {
1660 self.page.inner_text(self.selector.clone()).await
1661 }
1662
1663 pub async fn wait_for(&self) -> Result<WaitForSelectorResult> {
1664 self.page.wait_for_selector(self.selector.clone()).await
1665 }
1666}
1667
1668async fn get_runtime() -> Result<Arc<RuntimeClient>> {
1669 if let Ok(runtime) = runtime_slot().lock() {
1670 if let Some(existing) = runtime.as_ref() {
1671 return Ok(Arc::clone(existing));
1672 }
1673 }
1674
1675 let endpoint = configured_server_addr();
1676 let engine = EngineServiceClient::connect(endpoint).await?;
1677 let runtime = Arc::new(RuntimeClient { engine });
1678
1679 let mut slot = runtime_slot()
1680 .lock()
1681 .map_err(|_| Error::new("runtime singleton lock is poisoned"))?;
1682 if let Some(existing) = slot.as_ref() {
1683 return Ok(Arc::clone(existing));
1684 }
1685 *slot = Some(Arc::clone(&runtime));
1686 Ok(runtime)
1687}
1688
1689fn runtime_slot() -> &'static Mutex<Option<Arc<RuntimeClient>>> {
1690 RUNTIME.get_or_init(|| Mutex::new(None))
1691}
1692
1693fn server_addr_override_slot() -> &'static Mutex<Option<String>> {
1694 SERVER_ADDR_OVERRIDE.get_or_init(|| Mutex::new(None))
1695}
1696
1697fn configured_server_addr() -> String {
1698 if let Ok(server_addr_override) = server_addr_override_slot().lock() {
1699 if let Some(server_addr) = server_addr_override.as_ref() {
1700 return server_addr.clone();
1701 }
1702 }
1703
1704 normalize_server_addr(
1705 std::env::var(SERVER_ADDR_ENV_VAR)
1706 .ok()
1707 .filter(|value| !value.trim().is_empty())
1708 .as_deref()
1709 .unwrap_or(DEFAULT_SERVER_ADDR),
1710 )
1711}
1712
1713fn merge_launch_options(
1714 base: Option<LaunchOptions>,
1715 override_options: Option<LaunchOptions>,
1716) -> LaunchOptions {
1717 let mut merged = base.unwrap_or_default();
1718 if let Some(override_options) = override_options {
1719 if override_options.browser_binary.is_some() {
1720 merged.browser_binary = override_options.browser_binary;
1721 }
1722 if override_options.timeout_ms.is_some() {
1723 merged.timeout_ms = override_options.timeout_ms;
1724 }
1725 }
1726 merged
1727}
1728
1729fn merge_retry_config(
1730 base: Option<RetryConfig>,
1731 override_config: Option<RetryConfig>,
1732) -> RetryConfig {
1733 let mut merged = base.unwrap_or_default();
1734 if let Some(override_config) = override_config {
1735 if override_config.timeout_ms.is_some() {
1736 merged.timeout_ms = override_config.timeout_ms;
1737 }
1738 if override_config.interval_ms.is_some() {
1739 merged.interval_ms = override_config.interval_ms;
1740 }
1741 }
1742 merged
1743}
1744
1745fn validate_config_shape(config: &AllwrightConfig, source: &Path) -> Result<()> {
1746 if let Some(schema_version) = config.schema_version {
1747 if schema_version != 1 {
1748 return Err(Error::new(format!(
1749 "allwright config {} has unsupported schemaVersion {}; expected 1",
1750 source.display(),
1751 schema_version
1752 )));
1753 }
1754 }
1755 Ok(())
1756}
1757
1758fn normalize_server_addr(raw: &str) -> String {
1759 let trimmed = raw.trim();
1760 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
1761 trimmed.to_string()
1762 } else {
1763 format!("http://{trimmed}")
1764 }
1765}
1766
1767fn command_retry_options(timeout_ms: Option<u32>) -> Option<CommandRetryOptions> {
1768 timeout_ms.map(|timeout_ms| CommandRetryOptions {
1769 timeout_ms: Some(timeout_ms),
1770 retry_interval_ms: None,
1771 })
1772}
1773
1774fn count_result_from_event(event: ElementCountedEvent) -> CountResult {
1775 CountResult {
1776 selector: event.css_selector,
1777 count: event.count,
1778 note: event.note,
1779 }
1780}
1781
1782fn highlight_result_from_event(event: ElementsHighlightedEvent) -> HighlightResult {
1783 HighlightResult {
1784 selector: event.css_selector,
1785 count: event.count,
1786 note: event.note,
1787 }
1788}