Skip to main content

chrome_for_testing_manager/
session_builder.rs

1use crate::ChromeForTestingManagerError;
2use crate::chromedriver::Chromedriver;
3use crate::mgr::{HeadlessShellSession, LoadedBrowserPackage};
4use crate::session::Session;
5use rootcause::prelude::ResultExt;
6use rootcause::{IntoReportCollection, Report, markers::SendSync};
7use thirtyfour::ChromiumLikeCapabilities;
8use thirtyfour::prelude::WebDriverError;
9use thirtyfour::{ChromeCapabilities, WebDriverBuilder};
10
11/// Type-state marker: no capability customization will be applied to the session.
12#[doc(hidden)]
13#[derive(Debug, Clone, Copy)]
14pub struct InitialCaps;
15
16/// Type-state marker: no `WebDriverBuilder` customization will be applied to the session.
17#[doc(hidden)]
18#[derive(Debug, Clone, Copy)]
19pub struct InitialConfig;
20
21/// Type-state wrapper carrying a user-provided capabilities setup closure.
22#[doc(hidden)]
23pub struct CapsSetup<F>(F);
24
25/// Type-state wrapper carrying a user-provided `WebDriverBuilder` setup closure.
26#[doc(hidden)]
27pub struct ConfigSetup<F>(F);
28
29mod sealed {
30    pub trait Sealed {}
31}
32
33#[doc(hidden)]
34pub trait ApplyCaps: sealed::Sealed {
35    fn apply(self, caps: &mut ChromeCapabilities) -> Result<(), WebDriverError>;
36}
37
38impl sealed::Sealed for InitialCaps {}
39impl ApplyCaps for InitialCaps {
40    fn apply(self, _caps: &mut ChromeCapabilities) -> Result<(), WebDriverError> {
41        Ok(())
42    }
43}
44
45impl<F> sealed::Sealed for CapsSetup<F> {}
46impl<F> ApplyCaps for CapsSetup<F>
47where
48    F: FnOnce(&mut ChromeCapabilities) -> Result<(), WebDriverError>,
49{
50    fn apply(self, caps: &mut ChromeCapabilities) -> Result<(), WebDriverError> {
51        self.0(caps)
52    }
53}
54
55#[doc(hidden)]
56pub trait ApplyConfig: sealed::Sealed {
57    fn apply(self, builder: WebDriverBuilder) -> WebDriverBuilder;
58}
59
60impl sealed::Sealed for InitialConfig {}
61impl ApplyConfig for InitialConfig {
62    fn apply(self, builder: WebDriverBuilder) -> WebDriverBuilder {
63        builder
64    }
65}
66
67impl<F> sealed::Sealed for ConfigSetup<F> {}
68impl<F> ApplyConfig for ConfigSetup<F>
69where
70    F: FnOnce(WebDriverBuilder) -> WebDriverBuilder,
71{
72    fn apply(self, builder: WebDriverBuilder) -> WebDriverBuilder {
73        self.0(builder)
74    }
75}
76
77/// A scoped, chainable builder for opening a `thirtyfour` [`Session`] against a running
78/// [`Chromedriver`].
79///
80/// Obtained via [`Chromedriver::session`]. Optional setup steps:
81///
82/// - [`Self::with_caps`] mutates the [`ChromeCapabilities`] before the session opens (e.g. unset
83///   headless, add Chrome args).
84/// - [`Self::with_config`] receives the [`WebDriverBuilder`] and may configure the element poller,
85///   request timeout, user-agent, or keep-alive flag.
86///
87/// Call [`Self::run`] to open the session and execute the user closure inside scoped, panic-safe
88/// cleanup that always calls `WebDriver::quit().await`.
89pub struct SessionBuilder<'a, C, B> {
90    chromedriver: &'a Chromedriver,
91    caps_setup: C,
92    config_setup: B,
93}
94
95impl<'a> SessionBuilder<'a, InitialCaps, InitialConfig> {
96    pub(crate) fn new(chromedriver: &'a Chromedriver) -> Self {
97        Self {
98            chromedriver,
99            caps_setup: InitialCaps,
100            config_setup: InitialConfig,
101        }
102    }
103}
104
105impl<'a, B> SessionBuilder<'a, InitialCaps, B> {
106    /// Provide a closure that mutates the [`ChromeCapabilities`] used to create the session.
107    pub fn with_caps<F>(self, f: F) -> SessionBuilder<'a, CapsSetup<F>, B>
108    where
109        F: FnOnce(&mut ChromeCapabilities) -> Result<(), WebDriverError>,
110    {
111        SessionBuilder {
112            chromedriver: self.chromedriver,
113            caps_setup: CapsSetup(f),
114            config_setup: self.config_setup,
115        }
116    }
117}
118
119impl<'a, C> SessionBuilder<'a, C, InitialConfig> {
120    /// Provide a closure that configures the [`WebDriverBuilder`] before the session is opened.
121    pub fn with_config<F>(self, f: F) -> SessionBuilder<'a, C, ConfigSetup<F>>
122    where
123        F: FnOnce(WebDriverBuilder) -> WebDriverBuilder,
124    {
125        SessionBuilder {
126            chromedriver: self.chromedriver,
127            caps_setup: self.caps_setup,
128            config_setup: ConfigSetup(f),
129        }
130    }
131}
132
133impl<C, B> SessionBuilder<'_, C, B>
134where
135    C: ApplyCaps,
136    B: ApplyConfig,
137{
138    /// Open a [`Session`], hand it to the user closure, and tear it down once the closure resolves
139    /// or panics.
140    ///
141    /// Cleanup runs regardless of outcome. A panic in the user closure is caught, the session is
142    /// quit, and the panic is resumed afterwards.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if capability setup, session creation, the user closure, or the quit call
147    /// fails. A user error combined with a quit error is reported with the quit error attached as
148    /// a child.
149    pub async fn run<T, E, F>(self, f: F) -> Result<T, Report<ChromeForTestingManagerError>>
150    where
151        F: for<'b> AsyncFnOnce(&'b Session) -> Result<T, E>,
152        E: IntoReportCollection<SendSync>,
153    {
154        use futures::FutureExt;
155
156        let chromedriver = self.chromedriver;
157        let port = chromedriver.port();
158
159        let mut caps = chromedriver.mgr.prepare_caps(&chromedriver.loaded)?;
160        self.caps_setup
161            .apply(&mut caps)
162            .context(ChromeForTestingManagerError::ConfigureSessionCapabilities)?;
163        let mut headless_shell = match &chromedriver.loaded {
164            LoadedBrowserPackage::Chrome(_) => None,
165            LoadedBrowserPackage::ChromeHeadlessShell(headless_shell_package) => {
166                let headless_shell = chromedriver
167                    .mgr
168                    .launch_headless_shell_session(
169                        headless_shell_package,
170                        &caps,
171                        chromedriver.graceful_shutdown.clone(),
172                    )
173                    .await?;
174
175                caps.set_debugger_address(headless_shell.debugger_address())
176                    .context(ChromeForTestingManagerError::ConfigureSessionCapabilities)?;
177
178                Some(headless_shell)
179            }
180        };
181        let builder = thirtyfour::WebDriver::builder(format!("http://localhost:{port}"), caps);
182        let driver = match self
183            .config_setup
184            .apply(builder)
185            .connect()
186            .await
187            .context(ChromeForTestingManagerError::StartWebDriverSession { port })
188        {
189            Ok(driver) => driver,
190            Err(err) => {
191                if let Err(termination_err) = terminate_headless_shell(headless_shell.take()).await
192                {
193                    tracing::warn!(
194                        error = %termination_err,
195                        "failed to terminate Chrome Headless Shell after WebDriver session start failure"
196                    );
197                }
198                return Err(err);
199            }
200        };
201
202        let session = Session { driver };
203
204        let maybe_panicked = core::panic::AssertUnwindSafe(f(&session))
205            .catch_unwind()
206            .await;
207
208        let user_result = match maybe_panicked {
209            Ok(result) => result.context(ChromeForTestingManagerError::RunSessionCallback),
210            Err(payload) => {
211                if let Err(quit_err) = session.quit().await {
212                    tracing::error!(
213                        "Failed to quit WebDriver session after user callback panic: {quit_err:?}"
214                    );
215                }
216                if let Err(terminate_err) = terminate_headless_shell(headless_shell).await {
217                    tracing::error!(
218                        "Failed to terminate Chrome Headless Shell after user callback panic: {terminate_err:?}"
219                    );
220                }
221                std::panic::resume_unwind(payload);
222            }
223        };
224
225        let quit_result = session.quit().await;
226        let browser_termination_result = terminate_headless_shell(headless_shell).await;
227
228        let session_result = match (user_result, quit_result) {
229            (Ok(value), Ok(())) => Ok(value),
230            (Ok(_), Err(quit_err)) => Err(quit_err),
231            (Err(user_err), Ok(())) => Err(user_err),
232            (Err(mut user_err), Err(quit_err)) => {
233                tracing::error!(
234                    "Failed to quit WebDriver session after user failure: {quit_err:?}"
235                );
236                user_err
237                    .children_mut()
238                    .push(quit_err.into_dynamic().into_cloneable());
239                Err(user_err)
240            }
241        };
242
243        match (session_result, browser_termination_result) {
244            (Ok(value), Ok(())) => Ok(value),
245            (Ok(_), Err(terminate_err)) => Err(terminate_err),
246            (Err(session_err), Ok(())) => Err(session_err),
247            (Err(mut session_err), Err(terminate_err)) => {
248                tracing::error!(
249                    "Failed to terminate Chrome Headless Shell after session failure: {terminate_err:?}"
250                );
251                session_err
252                    .children_mut()
253                    .push(terminate_err.into_dynamic().into_cloneable());
254                Err(session_err)
255            }
256        }
257    }
258}
259
260async fn terminate_headless_shell(
261    headless_shell: Option<HeadlessShellSession>,
262) -> Result<(), Report<ChromeForTestingManagerError>> {
263    if let Some(headless_shell) = headless_shell {
264        headless_shell.terminate().await?;
265    }
266    Ok(())
267}