viewpoint-test 0.4.1

Test framework for Viewpoint browser automation with Playwright-style assertions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Test harness for browser automation tests.
//!
//! The [`TestHarness`] manages browser lifecycle (launch and cleanup) automatically,
//! providing a clean fixture for each test case.
//!
//! # Automatic Browser Lifecycle Management
//!
//! The harness handles all setup and teardown automatically:
//!
//! ```ignore
//! use viewpoint_test::TestHarness;
//!
//! #[tokio::test]
//! async fn test_example() -> Result<(), Box<dyn std::error::Error>> {
//!     // Setup: launches browser, creates context and page
//!     let harness = TestHarness::new().await?;
//!     let page = harness.page();
//!
//!     page.goto("https://example.com").goto().await?;
//!     // ... test logic ...
//!
//!     Ok(())  // Cleanup: browser is automatically closed when harness is dropped
//! }
//! ```
//!
//! # Sharing Browser Across Multiple Tests (Module-Scoped Fixtures)
//!
//! For faster test execution, share a browser across multiple tests:
//!
//! ```ignore
//! use viewpoint_test::TestHarness;
//! use viewpoint_core::Browser;
//! use std::sync::OnceLock;
//!
//! // Shared browser for all tests in this module
//! static SHARED_BROWSER: OnceLock<Browser> = OnceLock::new();
//!
//! async fn get_shared_browser() -> &'static Browser {
//!     if let Some(browser) = SHARED_BROWSER.get() {
//!         return browser;
//!     }
//!     let browser = Browser::launch().headless(true).launch().await.unwrap();
//!     SHARED_BROWSER.set(browser).unwrap();
//!     SHARED_BROWSER.get().unwrap()
//! }
//!
//! #[tokio::test]
//! async fn test_one() -> Result<(), Box<dyn std::error::Error>> {
//!     let browser = get_shared_browser().await;
//!     // Fresh context and page, reuses browser
//!     let harness = TestHarness::from_browser(browser).await?;
//!     let page = harness.page();
//!     
//!     page.goto("https://example.com").goto().await?;
//!     Ok(())  // Context and page cleaned up, browser stays alive
//! }
//!
//! #[tokio::test]
//! async fn test_two() -> Result<(), Box<dyn std::error::Error>> {
//!     let browser = get_shared_browser().await;
//!     let harness = TestHarness::from_browser(browser).await?;
//!     let page = harness.page();
//!     
//!     page.goto("https://example.org").goto().await?;
//!     Ok(())
//! }
//! ```
//!
//! # Fixture Scoping Levels
//!
//! | Method | Browser | Context | Page | Use Case |
//! |--------|---------|---------|------|----------|
//! | `TestHarness::new()` | New | New | New | Full isolation (default) |
//! | `TestHarness::from_browser(&browser)` | Shared | New | New | Faster tests, context isolation |
//! | `TestHarness::from_context(&context)` | Shared | Shared | New | Share cookies/state across tests |
//!
//! # Custom Configuration
//!
//! ```ignore
//! use viewpoint_test::{TestHarness, TestConfig};
//! use std::time::Duration;
//!
//! #[tokio::test]
//! async fn test_with_config() -> Result<(), Box<dyn std::error::Error>> {
//!     let harness = TestHarness::builder()
//!         .headless(false)  // Show browser for debugging
//!         .timeout(Duration::from_secs(60))
//!         .build()
//!         .await?;
//!     
//!     // Or use TestConfig directly
//!     let config = TestConfig::builder()
//!         .headless(true)
//!         .timeout(Duration::from_secs(30))
//!         .build();
//!     let harness = TestHarness::with_config(config).await?;
//!     
//!     Ok(())
//! }
//! ```

use tracing::{debug, info, instrument, warn};

use crate::config::TestConfig;
use crate::error::TestError;
use viewpoint_core::{Browser, BrowserContext, Page};

/// Test harness that manages browser, context, and page lifecycle.
///
/// The harness provides access to browser automation fixtures and handles
/// cleanup automatically via `Drop`. It supports different scoping levels
/// to balance test isolation against performance.
///
/// # Scoping Levels
///
/// - `TestHarness::new()` - Test-scoped: new browser per test (default)
/// - `TestHarness::from_browser()` - Module-scoped: reuse browser, fresh context/page
/// - `TestHarness::from_context()` - Shared context: reuse context, fresh page only
///
/// # Example
///
/// ```no_run
/// use viewpoint_test::TestHarness;
///
/// #[tokio::test]
/// async fn my_test() -> Result<(), Box<dyn std::error::Error>> {
///     let harness = TestHarness::new().await?;
///     let page = harness.page();
///
///     page.goto("https://example.com").goto().await?;
///
///     Ok(()) // harness drops and cleans up
/// }
/// ```
#[derive(Debug)]
pub struct TestHarness {
    /// The browser instance.
    browser: Option<Browser>,
    /// The browser context.
    context: Option<BrowserContext>,
    /// The page.
    page: Page,
    /// Whether we own the browser (should close on drop).
    owns_browser: bool,
    /// Whether we own the context (should close on drop).
    owns_context: bool,
    /// Test configuration.
    config: TestConfig,
}

impl TestHarness {
    /// Create a new test harness with default configuration.
    ///
    /// This creates a new browser, context, and page for the test.
    /// All resources are owned and will be cleaned up on drop.
    ///
    /// # Errors
    ///
    /// Returns an error if browser launch or page creation fails.
    #[instrument(level = "info", name = "TestHarness::new")]
    pub async fn new() -> Result<Self, TestError> {
        Self::with_config(TestConfig::default()).await
    }

    /// Create a new test harness with custom configuration.
    ///
    /// # Errors
    ///
    /// Returns an error if browser launch or page creation fails.
    #[instrument(level = "info", name = "TestHarness::with_config", skip(config))]
    pub async fn with_config(config: TestConfig) -> Result<Self, TestError> {
        info!(headless = config.headless, "Creating test harness");

        let browser = Browser::launch()
            .headless(config.headless)
            .launch()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to launch browser: {e}")))?;

        debug!("Browser launched");

        let context = browser
            .new_context()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;

        debug!("Context created");

        let page = context
            .new_page()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;

        debug!("Page created");

        info!("Test harness ready");

        Ok(Self {
            browser: Some(browser),
            context: Some(context),
            page,
            owns_browser: true,
            owns_context: true,
            config,
        })
    }

    /// Create a test harness builder for custom configuration.
    pub fn builder() -> TestHarnessBuilder {
        TestHarnessBuilder::default()
    }

    /// Create a test harness using an existing browser.
    ///
    /// This creates a new context and page in the provided browser.
    /// The browser will NOT be closed when the harness is dropped.
    ///
    /// # Errors
    ///
    /// Returns an error if context or page creation fails.
    #[instrument(level = "info", name = "TestHarness::from_browser", skip(browser))]
    pub async fn from_browser(browser: &Browser) -> Result<Self, TestError> {
        info!("Creating test harness from existing browser");

        let context = browser
            .new_context()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create context: {e}")))?;

        debug!("Context created");

        let page = context
            .new_page()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;

        debug!("Page created");

        info!("Test harness ready (browser shared)");

        Ok(Self {
            browser: None, // We don't own the browser
            context: Some(context),
            page,
            owns_browser: false,
            owns_context: true,
            config: TestConfig::default(),
        })
    }

    /// Create a test harness using an existing context.
    ///
    /// This creates a new page in the provided context.
    /// Neither the browser nor context will be closed when the harness is dropped.
    ///
    /// # Errors
    ///
    /// Returns an error if page creation fails.
    #[instrument(level = "info", name = "TestHarness::from_context", skip(context))]
    pub async fn from_context(context: &BrowserContext) -> Result<Self, TestError> {
        info!("Creating test harness from existing context");

        let page = context
            .new_page()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))?;

        debug!("Page created");

        info!("Test harness ready (context shared)");

        Ok(Self {
            browser: None,
            context: None, // We don't own the context
            page,
            owns_browser: false,
            owns_context: false,
            config: TestConfig::default(),
        })
    }

    /// Get a reference to the page.
    pub fn page(&self) -> &Page {
        &self.page
    }

    /// Get a mutable reference to the page.
    pub fn page_mut(&mut self) -> &mut Page {
        &mut self.page
    }

    /// Get a reference to the browser context.
    ///
    /// Returns `None` if this harness was created with `from_context()`.
    pub fn context(&self) -> Option<&BrowserContext> {
        self.context.as_ref()
    }

    /// Get a reference to the browser.
    ///
    /// Returns `None` if this harness was created with `from_browser()` or `from_context()`.
    pub fn browser(&self) -> Option<&Browser> {
        self.browser.as_ref()
    }

    /// Get the test configuration.
    pub fn config(&self) -> &TestConfig {
        &self.config
    }

    /// Create a new page in the same context.
    ///
    /// # Errors
    ///
    /// Returns an error if page creation fails or if no context is available.
    pub async fn new_page(&self) -> Result<Page, TestError> {
        let context = self.context.as_ref().ok_or_else(|| {
            TestError::Setup("No context available (harness created with from_context)".to_string())
        })?;

        context
            .new_page()
            .await
            .map_err(|e| TestError::Setup(format!("Failed to create page: {e}")))
    }

    /// Explicitly close all owned resources.
    ///
    /// This is called automatically on drop, but can be called explicitly
    /// to handle cleanup errors.
    ///
    /// # Errors
    ///
    /// Returns an error if cleanup fails.
    #[instrument(level = "info", name = "TestHarness::close", skip(self))]
    pub async fn close(mut self) -> Result<(), TestError> {
        info!(
            owns_browser = self.owns_browser,
            owns_context = self.owns_context,
            "Closing test harness"
        );

        // Close page
        if let Err(e) = self.page.close().await {
            warn!("Failed to close page: {}", e);
        }

        // Close context if we own it
        if self.owns_context {
            if let Some(ref mut context) = self.context {
                if let Err(e) = context.close().await {
                    warn!("Failed to close context: {}", e);
                }
            }
        }

        // Close browser if we own it
        if self.owns_browser {
            if let Some(ref browser) = self.browser {
                if let Err(e) = browser.close().await {
                    warn!("Failed to close browser: {}", e);
                }
            }
        }

        info!("Test harness closed");
        Ok(())
    }
}

impl Drop for TestHarness {
    fn drop(&mut self) {
        // We can't do async cleanup in Drop, so we rely on the underlying
        // types' Drop implementations. Browser::drop will kill the process
        // if we own it.
        debug!(
            owns_browser = self.owns_browser,
            owns_context = self.owns_context,
            "TestHarness dropped"
        );
    }
}

/// Builder for `TestHarness`.
#[derive(Debug, Default)]
pub struct TestHarnessBuilder {
    config: TestConfig,
}

impl TestHarnessBuilder {
    /// Set whether to run in headless mode.
    pub fn headless(mut self, headless: bool) -> Self {
        self.config.headless = headless;
        self
    }

    /// Set the default timeout.
    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
        self.config.timeout = timeout;
        self
    }

    /// Build and initialize the test harness.
    ///
    /// # Errors
    ///
    /// Returns an error if browser launch or page creation fails.
    pub async fn build(self) -> Result<TestHarness, TestError> {
        TestHarness::with_config(self.config).await
    }
}