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}