viewpoint_test/harness/
mod.rs

1//! Test harness for browser automation tests.
2
3use tracing::{debug, info, instrument, warn};
4
5use crate::config::TestConfig;
6use crate::error::TestError;
7use viewpoint_core::{Browser, BrowserContext, Page};
8
9/// Test harness that manages browser, context, and page lifecycle.
10///
11/// The harness provides access to browser automation fixtures and handles
12/// cleanup automatically via `Drop`. It supports different scoping levels
13/// to balance test isolation against performance.
14///
15/// # Scoping Levels
16///
17/// - `TestHarness::new()` - Test-scoped: new browser per test (default)
18/// - `TestHarness::from_browser()` - Module-scoped: reuse browser, fresh context/page
19/// - `TestHarness::from_context()` - Shared context: reuse context, fresh page only
20///
21/// # Example
22///
23/// ```no_run
24/// use viewpoint_test::TestHarness;
25///
26/// #[tokio::test]
27/// async fn my_test() -> Result<(), Box<dyn std::error::Error>> {
28///     let harness = TestHarness::new().await?;
29///     let page = harness.page();
30///
31///     page.goto("https://example.com").goto().await?;
32///
33///     Ok(()) // harness drops and cleans up
34/// }
35/// ```
36#[derive(Debug)]
37pub struct TestHarness {
38    /// The browser instance.
39    browser: Option<Browser>,
40    /// The browser context.
41    context: Option<BrowserContext>,
42    /// The page.
43    page: Page,
44    /// Whether we own the browser (should close on drop).
45    owns_browser: bool,
46    /// Whether we own the context (should close on drop).
47    owns_context: bool,
48    /// Test configuration.
49    config: TestConfig,
50}
51
52impl TestHarness {
53    /// Create a new test harness with default configuration.
54    ///
55    /// This creates a new browser, context, and page for the test.
56    /// All resources are owned and will be cleaned up on drop.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if browser launch or page creation fails.
61    #[instrument(level = "info", name = "TestHarness::new")]
62    pub async fn new() -> Result<Self, TestError> {
63        Self::with_config(TestConfig::default()).await
64    }
65
66    /// Create a new test harness with custom configuration.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if browser launch or page creation fails.
71    #[instrument(level = "info", name = "TestHarness::with_config", skip(config))]
72    pub async fn with_config(config: TestConfig) -> Result<Self, TestError> {
73        info!(headless = config.headless, "Creating test harness");
74
75        let browser = Browser::launch()
76            .headless(config.headless)
77            .launch()
78            .await
79            .map_err(|e| TestError::Setup(format!("Failed to launch browser: {e}")))?;
80
81        debug!("Browser launched");
82
83        let context = browser
84            .new_context()
85            .await
86            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;
87
88        debug!("Context created");
89
90        let page = context
91            .new_page()
92            .await
93            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
94
95        debug!("Page created");
96
97        info!("Test harness ready");
98
99        Ok(Self {
100            browser: Some(browser),
101            context: Some(context),
102            page,
103            owns_browser: true,
104            owns_context: true,
105            config,
106        })
107    }
108
109    /// Create a test harness builder for custom configuration.
110    pub fn builder() -> TestHarnessBuilder {
111        TestHarnessBuilder::default()
112    }
113
114    /// Create a test harness using an existing browser.
115    ///
116    /// This creates a new context and page in the provided browser.
117    /// The browser will NOT be closed when the harness is dropped.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if context or page creation fails.
122    #[instrument(level = "info", name = "TestHarness::from_browser", skip(browser))]
123    pub async fn from_browser(browser: &Browser) -> Result<Self, TestError> {
124        info!("Creating test harness from existing browser");
125
126        let context = browser
127            .new_context()
128            .await
129            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;
130
131        debug!("Context created");
132
133        let page = context
134            .new_page()
135            .await
136            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
137
138        debug!("Page created");
139
140        info!("Test harness ready (browser shared)");
141
142        Ok(Self {
143            browser: None, // We don't own the browser
144            context: Some(context),
145            page,
146            owns_browser: false,
147            owns_context: true,
148            config: TestConfig::default(),
149        })
150    }
151
152    /// Create a test harness using an existing context.
153    ///
154    /// This creates a new page in the provided context.
155    /// Neither the browser nor context will be closed when the harness is dropped.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if page creation fails.
160    #[instrument(level = "info", name = "TestHarness::from_context", skip(context))]
161    pub async fn from_context(context: &BrowserContext) -> Result<Self, TestError> {
162        info!("Creating test harness from existing context");
163
164        let page = context
165            .new_page()
166            .await
167            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
168
169        debug!("Page created");
170
171        info!("Test harness ready (context shared)");
172
173        Ok(Self {
174            browser: None,
175            context: None, // We don't own the context
176            page,
177            owns_browser: false,
178            owns_context: false,
179            config: TestConfig::default(),
180        })
181    }
182
183    /// Get a reference to the page.
184    pub fn page(&self) -> &Page {
185        &self.page
186    }
187
188    /// Get a mutable reference to the page.
189    pub fn page_mut(&mut self) -> &mut Page {
190        &mut self.page
191    }
192
193    /// Get a reference to the browser context.
194    ///
195    /// Returns `None` if this harness was created with `from_context()`.
196    pub fn context(&self) -> Option<&BrowserContext> {
197        self.context.as_ref()
198    }
199
200    /// Get a reference to the browser.
201    ///
202    /// Returns `None` if this harness was created with `from_browser()` or `from_context()`.
203    pub fn browser(&self) -> Option<&Browser> {
204        self.browser.as_ref()
205    }
206
207    /// Get the test configuration.
208    pub fn config(&self) -> &TestConfig {
209        &self.config
210    }
211
212    /// Create a new page in the same context.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if page creation fails or if no context is available.
217    pub async fn new_page(&self) -> Result<Page, TestError> {
218        let context = self.context.as_ref().ok_or_else(|| {
219            TestError::Setup("No context available (harness created with from_context)".to_string())
220        })?;
221
222        context
223            .new_page()
224            .await
225            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))
226    }
227
228    /// Explicitly close all owned resources.
229    ///
230    /// This is called automatically on drop, but can be called explicitly
231    /// to handle cleanup errors.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if cleanup fails.
236    #[instrument(level = "info", name = "TestHarness::close", skip(self))]
237    pub async fn close(mut self) -> Result<(), TestError> {
238        info!(
239            owns_browser = self.owns_browser,
240            owns_context = self.owns_context,
241            "Closing test harness"
242        );
243
244        // Close page
245        if let Err(e) = self.page.close().await {
246            warn!("Failed to close page: {}", e);
247        }
248
249        // Close context if we own it
250        if self.owns_context {
251            if let Some(ref mut context) = self.context {
252                if let Err(e) = context.close().await {
253                    warn!("Failed to close context: {}", e);
254                }
255            }
256        }
257
258        // Close browser if we own it
259        if self.owns_browser {
260            if let Some(ref browser) = self.browser {
261                if let Err(e) = browser.close().await {
262                    warn!("Failed to close browser: {}", e);
263                }
264            }
265        }
266
267        info!("Test harness closed");
268        Ok(())
269    }
270}
271
272impl Drop for TestHarness {
273    fn drop(&mut self) {
274        // We can't do async cleanup in Drop, so we rely on the underlying
275        // types' Drop implementations. Browser::drop will kill the process
276        // if we own it.
277        debug!(
278            owns_browser = self.owns_browser,
279            owns_context = self.owns_context,
280            "TestHarness dropped"
281        );
282    }
283}
284
285/// Builder for `TestHarness`.
286#[derive(Debug, Default)]
287pub struct TestHarnessBuilder {
288    config: TestConfig,
289}
290
291impl TestHarnessBuilder {
292    /// Set whether to run in headless mode.
293    pub fn headless(mut self, headless: bool) -> Self {
294        self.config.headless = headless;
295        self
296    }
297
298    /// Set the default timeout.
299    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
300        self.config.timeout = timeout;
301        self
302    }
303
304    /// Build and initialize the test harness.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if browser launch or page creation fails.
309    pub async fn build(self) -> Result<TestHarness, TestError> {
310        TestHarness::with_config(self.config).await
311    }
312}