viewpoint_test/harness/
mod.rs

1//! Test harness for browser automation tests.
2//!
3//! The [`TestHarness`] manages browser lifecycle (launch and cleanup) automatically,
4//! providing a clean fixture for each test case.
5//!
6//! # Automatic Browser Lifecycle Management
7//!
8//! The harness handles all setup and teardown automatically:
9//!
10//! ```ignore
11//! use viewpoint_test::TestHarness;
12//!
13//! #[tokio::test]
14//! async fn test_example() -> Result<(), Box<dyn std::error::Error>> {
15//!     // Setup: launches browser, creates context and page
16//!     let harness = TestHarness::new().await?;
17//!     let page = harness.page();
18//!
19//!     page.goto("https://example.com").goto().await?;
20//!     // ... test logic ...
21//!
22//!     Ok(())  // Cleanup: browser is automatically closed when harness is dropped
23//! }
24//! ```
25//!
26//! # Sharing Browser Across Multiple Tests (Module-Scoped Fixtures)
27//!
28//! For faster test execution, share a browser across multiple tests:
29//!
30//! ```ignore
31//! use viewpoint_test::TestHarness;
32//! use viewpoint_core::Browser;
33//! use std::sync::OnceLock;
34//!
35//! // Shared browser for all tests in this module
36//! static SHARED_BROWSER: OnceLock<Browser> = OnceLock::new();
37//!
38//! async fn get_shared_browser() -> &'static Browser {
39//!     if let Some(browser) = SHARED_BROWSER.get() {
40//!         return browser;
41//!     }
42//!     let browser = Browser::launch().headless(true).launch().await.unwrap();
43//!     SHARED_BROWSER.set(browser).unwrap();
44//!     SHARED_BROWSER.get().unwrap()
45//! }
46//!
47//! #[tokio::test]
48//! async fn test_one() -> Result<(), Box<dyn std::error::Error>> {
49//!     let browser = get_shared_browser().await;
50//!     // Fresh context and page, reuses browser
51//!     let harness = TestHarness::from_browser(browser).await?;
52//!     let page = harness.page();
53//!     
54//!     page.goto("https://example.com").goto().await?;
55//!     Ok(())  // Context and page cleaned up, browser stays alive
56//! }
57//!
58//! #[tokio::test]
59//! async fn test_two() -> Result<(), Box<dyn std::error::Error>> {
60//!     let browser = get_shared_browser().await;
61//!     let harness = TestHarness::from_browser(browser).await?;
62//!     let page = harness.page();
63//!     
64//!     page.goto("https://example.org").goto().await?;
65//!     Ok(())
66//! }
67//! ```
68//!
69//! # Fixture Scoping Levels
70//!
71//! | Method | Browser | Context | Page | Use Case |
72//! |--------|---------|---------|------|----------|
73//! | `TestHarness::new()` | New | New | New | Full isolation (default) |
74//! | `TestHarness::from_browser(&browser)` | Shared | New | New | Faster tests, context isolation |
75//! | `TestHarness::from_context(&context)` | Shared | Shared | New | Share cookies/state across tests |
76//!
77//! # Custom Configuration
78//!
79//! ```ignore
80//! use viewpoint_test::{TestHarness, TestConfig};
81//! use std::time::Duration;
82//!
83//! #[tokio::test]
84//! async fn test_with_config() -> Result<(), Box<dyn std::error::Error>> {
85//!     let harness = TestHarness::builder()
86//!         .headless(false)  // Show browser for debugging
87//!         .timeout(Duration::from_secs(60))
88//!         .build()
89//!         .await?;
90//!     
91//!     // Or use TestConfig directly
92//!     let config = TestConfig::builder()
93//!         .headless(true)
94//!         .timeout(Duration::from_secs(30))
95//!         .build();
96//!     let harness = TestHarness::with_config(config).await?;
97//!     
98//!     Ok(())
99//! }
100//! ```
101
102use tracing::{debug, info, instrument, warn};
103
104use crate::config::TestConfig;
105use crate::error::TestError;
106use viewpoint_core::{Browser, BrowserContext, Page};
107
108/// Test harness that manages browser, context, and page lifecycle.
109///
110/// The harness provides access to browser automation fixtures and handles
111/// cleanup automatically via `Drop`. It supports different scoping levels
112/// to balance test isolation against performance.
113///
114/// # Scoping Levels
115///
116/// - `TestHarness::new()` - Test-scoped: new browser per test (default)
117/// - `TestHarness::from_browser()` - Module-scoped: reuse browser, fresh context/page
118/// - `TestHarness::from_context()` - Shared context: reuse context, fresh page only
119///
120/// # Example
121///
122/// ```no_run
123/// use viewpoint_test::TestHarness;
124///
125/// #[tokio::test]
126/// async fn my_test() -> Result<(), Box<dyn std::error::Error>> {
127///     let harness = TestHarness::new().await?;
128///     let page = harness.page();
129///
130///     page.goto("https://example.com").goto().await?;
131///
132///     Ok(()) // harness drops and cleans up
133/// }
134/// ```
135#[derive(Debug)]
136pub struct TestHarness {
137    /// The browser instance.
138    browser: Option<Browser>,
139    /// The browser context.
140    context: Option<BrowserContext>,
141    /// The page.
142    page: Page,
143    /// Whether we own the browser (should close on drop).
144    owns_browser: bool,
145    /// Whether we own the context (should close on drop).
146    owns_context: bool,
147    /// Test configuration.
148    config: TestConfig,
149}
150
151impl TestHarness {
152    /// Create a new test harness with default configuration.
153    ///
154    /// This creates a new browser, context, and page for the test.
155    /// All resources are owned and will be cleaned up on drop.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if browser launch or page creation fails.
160    #[instrument(level = "info", name = "TestHarness::new")]
161    pub async fn new() -> Result<Self, TestError> {
162        Self::with_config(TestConfig::default()).await
163    }
164
165    /// Create a new test harness with custom configuration.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if browser launch or page creation fails.
170    #[instrument(level = "info", name = "TestHarness::with_config", skip(config))]
171    pub async fn with_config(config: TestConfig) -> Result<Self, TestError> {
172        info!(headless = config.headless, "Creating test harness");
173
174        let browser = Browser::launch()
175            .headless(config.headless)
176            .launch()
177            .await
178            .map_err(|e| TestError::Setup(format!("Failed to launch browser: {e}")))?;
179
180        debug!("Browser launched");
181
182        let context = browser
183            .new_context()
184            .await
185            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;
186
187        debug!("Context created");
188
189        let page = context
190            .new_page()
191            .await
192            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
193
194        debug!("Page created");
195
196        info!("Test harness ready");
197
198        Ok(Self {
199            browser: Some(browser),
200            context: Some(context),
201            page,
202            owns_browser: true,
203            owns_context: true,
204            config,
205        })
206    }
207
208    /// Create a test harness builder for custom configuration.
209    pub fn builder() -> TestHarnessBuilder {
210        TestHarnessBuilder::default()
211    }
212
213    /// Create a test harness using an existing browser.
214    ///
215    /// This creates a new context and page in the provided browser.
216    /// The browser will NOT be closed when the harness is dropped.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if context or page creation fails.
221    #[instrument(level = "info", name = "TestHarness::from_browser", skip(browser))]
222    pub async fn from_browser(browser: &Browser) -> Result<Self, TestError> {
223        info!("Creating test harness from existing browser");
224
225        let context = browser
226            .new_context()
227            .await
228            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;
229
230        debug!("Context created");
231
232        let page = context
233            .new_page()
234            .await
235            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
236
237        debug!("Page created");
238
239        info!("Test harness ready (browser shared)");
240
241        Ok(Self {
242            browser: None, // We don't own the browser
243            context: Some(context),
244            page,
245            owns_browser: false,
246            owns_context: true,
247            config: TestConfig::default(),
248        })
249    }
250
251    /// Create a test harness using an existing context.
252    ///
253    /// This creates a new page in the provided context.
254    /// Neither the browser nor context will be closed when the harness is dropped.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if page creation fails.
259    #[instrument(level = "info", name = "TestHarness::from_context", skip(context))]
260    pub async fn from_context(context: &BrowserContext) -> Result<Self, TestError> {
261        info!("Creating test harness from existing context");
262
263        let page = context
264            .new_page()
265            .await
266            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;
267
268        debug!("Page created");
269
270        info!("Test harness ready (context shared)");
271
272        Ok(Self {
273            browser: None,
274            context: None, // We don't own the context
275            page,
276            owns_browser: false,
277            owns_context: false,
278            config: TestConfig::default(),
279        })
280    }
281
282    /// Get a reference to the page.
283    pub fn page(&self) -> &Page {
284        &self.page
285    }
286
287    /// Get a mutable reference to the page.
288    pub fn page_mut(&mut self) -> &mut Page {
289        &mut self.page
290    }
291
292    /// Get a reference to the browser context.
293    ///
294    /// Returns `None` if this harness was created with `from_context()`.
295    pub fn context(&self) -> Option<&BrowserContext> {
296        self.context.as_ref()
297    }
298
299    /// Get a reference to the browser.
300    ///
301    /// Returns `None` if this harness was created with `from_browser()` or `from_context()`.
302    pub fn browser(&self) -> Option<&Browser> {
303        self.browser.as_ref()
304    }
305
306    /// Get the test configuration.
307    pub fn config(&self) -> &TestConfig {
308        &self.config
309    }
310
311    /// Create a new page in the same context.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error if page creation fails or if no context is available.
316    pub async fn new_page(&self) -> Result<Page, TestError> {
317        let context = self.context.as_ref().ok_or_else(|| {
318            TestError::Setup("No context available (harness created with from_context)".to_string())
319        })?;
320
321        context
322            .new_page()
323            .await
324            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))
325    }
326
327    /// Explicitly close all owned resources.
328    ///
329    /// This is called automatically on drop, but can be called explicitly
330    /// to handle cleanup errors.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if cleanup fails.
335    #[instrument(level = "info", name = "TestHarness::close", skip(self))]
336    pub async fn close(mut self) -> Result<(), TestError> {
337        info!(
338            owns_browser = self.owns_browser,
339            owns_context = self.owns_context,
340            "Closing test harness"
341        );
342
343        // Close page
344        if let Err(e) = self.page.close().await {
345            warn!("Failed to close page: {}", e);
346        }
347
348        // Close context if we own it
349        if self.owns_context {
350            if let Some(ref mut context) = self.context {
351                if let Err(e) = context.close().await {
352                    warn!("Failed to close context: {}", e);
353                }
354            }
355        }
356
357        // Close browser if we own it
358        if self.owns_browser {
359            if let Some(ref browser) = self.browser {
360                if let Err(e) = browser.close().await {
361                    warn!("Failed to close browser: {}", e);
362                }
363            }
364        }
365
366        info!("Test harness closed");
367        Ok(())
368    }
369}
370
371impl Drop for TestHarness {
372    fn drop(&mut self) {
373        // We can't do async cleanup in Drop, so we rely on the underlying
374        // types' Drop implementations. Browser::drop will kill the process
375        // if we own it.
376        debug!(
377            owns_browser = self.owns_browser,
378            owns_context = self.owns_context,
379            "TestHarness dropped"
380        );
381    }
382}
383
384/// Builder for `TestHarness`.
385#[derive(Debug, Default)]
386pub struct TestHarnessBuilder {
387    config: TestConfig,
388}
389
390impl TestHarnessBuilder {
391    /// Set whether to run in headless mode.
392    pub fn headless(mut self, headless: bool) -> Self {
393        self.config.headless = headless;
394        self
395    }
396
397    /// Set the default timeout.
398    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
399        self.config.timeout = timeout;
400        self
401    }
402
403    /// Build and initialize the test harness.
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if browser launch or page creation fails.
408    pub async fn build(self) -> Result<TestHarness, TestError> {
409        TestHarness::with_config(self.config).await
410    }
411}