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
219            .context
220            .as_ref()
221            .ok_or_else(|| TestError::Setup("No context available (harness created with from_context)".to_string()))?;
222
223        context
224            .new_page()
225            .await
226            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))
227    }
228
229    /// Explicitly close all owned resources.
230    ///
231    /// This is called automatically on drop, but can be called explicitly
232    /// to handle cleanup errors.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if cleanup fails.
237    #[instrument(level = "info", name = "TestHarness::close", skip(self))]
238    pub async fn close(mut self) -> Result<(), TestError> {
239        info!(owns_browser = self.owns_browser, owns_context = self.owns_context, "Closing test harness");
240
241        // Close page
242        if let Err(e) = self.page.close().await {
243            warn!("Failed to close page: {}", e);
244        }
245
246        // Close context if we own it
247        if self.owns_context {
248            if let Some(ref mut context) = self.context {
249                if let Err(e) = context.close().await {
250                    warn!("Failed to close context: {}", e);
251                }
252            }
253        }
254
255        // Close browser if we own it
256        if self.owns_browser {
257            if let Some(ref browser) = self.browser {
258                if let Err(e) = browser.close().await {
259                    warn!("Failed to close browser: {}", e);
260                }
261            }
262        }
263
264        info!("Test harness closed");
265        Ok(())
266    }
267}
268
269impl Drop for TestHarness {
270    fn drop(&mut self) {
271        // We can't do async cleanup in Drop, so we rely on the underlying
272        // types' Drop implementations. Browser::drop will kill the process
273        // if we own it.
274        debug!(owns_browser = self.owns_browser, owns_context = self.owns_context, "TestHarness dropped");
275    }
276}
277
278/// Builder for `TestHarness`.
279#[derive(Debug, Default)]
280pub struct TestHarnessBuilder {
281    config: TestConfig,
282}
283
284impl TestHarnessBuilder {
285    /// Set whether to run in headless mode.
286    pub fn headless(mut self, headless: bool) -> Self {
287        self.config.headless = headless;
288        self
289    }
290
291    /// Set the default timeout.
292    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
293        self.config.timeout = timeout;
294        self
295    }
296
297    /// Build and initialize the test harness.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if browser launch or page creation fails.
302    pub async fn build(self) -> Result<TestHarness, TestError> {
303        TestHarness::with_config(self.config).await
304    }
305}