Skip to main content

allwright/
client.rs

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