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}