Skip to main content

allwright/
client.rs

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