viewpoint_core/context/
mod.rs

1//! Browser context management.
2
3mod api;
4pub mod binding;
5mod cookies;
6mod emulation;
7pub mod events;
8mod har;
9mod page_events;
10mod page_factory;
11mod permissions;
12pub mod routing;
13mod routing_impl;
14mod scripts;
15pub mod storage;
16mod storage_restore;
17pub mod trace;
18mod test_id;
19mod tracing_access;
20pub mod types;
21mod weberror;
22
23pub use cookies::ClearCookiesBuilder;
24pub use emulation::SetGeolocationBuilder;
25
26// HashMap is used in emulation.rs
27use std::sync::Arc;
28use std::time::Duration;
29
30use tokio::sync::RwLock;
31use tracing::{debug, info, instrument};
32
33use viewpoint_cdp::protocol::target_domain::{
34    DisposeBrowserContextParams, GetTargetsParams, GetTargetsResult,
35};
36use viewpoint_cdp::CdpConnection;
37
38use crate::error::ContextError;
39use crate::page::Page;
40
41pub use events::{ContextEventManager, HandlerId};
42pub use weberror::WebErrorHandler;
43pub use storage::{StorageStateBuilder, StorageStateOptions};
44pub use trace::{Tracing, TracingOptions};
45use trace::TracingState;
46pub use types::{
47    ColorScheme, ContextOptions, ContextOptionsBuilder, Cookie, ForcedColors, Geolocation,
48    HttpCredentials, IndexedDbDatabase, IndexedDbEntry, IndexedDbIndex, IndexedDbObjectStore,
49    LocalStorageEntry, Permission, ReducedMotion, SameSite, StorageOrigin,
50    StorageState, StorageStateSource, ViewportSize,
51};
52// Re-export WebError for context-level usage
53pub use crate::page::page_error::WebError;
54
55/// Default test ID attribute name.
56pub const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";
57
58/// An isolated browser context.
59///
60/// Browser contexts are similar to incognito windows - they have their own
61/// cookies, cache, and storage that are isolated from other contexts.
62///
63/// # Features
64///
65/// - **Cookie Management**: Add, get, and clear cookies
66/// - **Storage State**: Save and restore browser state
67/// - **Permissions**: Grant permissions like geolocation, camera, etc.
68/// - **Geolocation**: Mock browser location
69/// - **HTTP Credentials**: Basic/Digest authentication
70/// - **Extra Headers**: Add headers to all requests
71/// - **Offline Mode**: Simulate network offline conditions
72/// - **Event Handling**: Listen for page creation and context close events
73/// - **Init Scripts**: Scripts that run before every page load
74/// - **Custom Test ID**: Configure which attribute is used for test IDs
75pub struct BrowserContext {
76    /// CDP connection.
77    connection: Arc<CdpConnection>,
78    /// Browser context ID.
79    context_id: String,
80    /// Whether the context has been closed.
81    closed: bool,
82    /// Created pages (weak tracking for `pages()` method).
83    pages: Arc<RwLock<Vec<PageInfo>>>,
84    /// Default timeout for actions.
85    default_timeout: Duration,
86    /// Default timeout for navigation.
87    default_navigation_timeout: Duration,
88    /// Context options used to create this context.
89    options: ContextOptions,
90    /// Web error handler.
91    weberror_handler: Arc<RwLock<Option<WebErrorHandler>>>,
92    /// Event manager for context-level events.
93    event_manager: Arc<ContextEventManager>,
94    /// Context-level route registry.
95    route_registry: Arc<routing::ContextRouteRegistry>,
96    /// Context-level binding registry.
97    binding_registry: Arc<binding::ContextBindingRegistry>,
98    /// Init scripts to run on every page load.
99    init_scripts: Arc<RwLock<Vec<String>>>,
100    /// Custom test ID attribute name (defaults to "data-testid").
101    test_id_attribute: Arc<RwLock<String>>,
102    /// HAR recorder for capturing network traffic.
103    har_recorder: Arc<RwLock<Option<crate::network::HarRecorder>>>,
104    /// Shared tracing state for persistent tracing across `tracing()` calls.
105    tracing_state: Arc<RwLock<TracingState>>,
106}
107
108// Manual Debug implementation since WebErrorHandler doesn't implement Debug
109impl std::fmt::Debug for BrowserContext {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("BrowserContext")
112            .field("context_id", &self.context_id)
113            .field("closed", &self.closed)
114            .field("default_timeout", &self.default_timeout)
115            .field("default_navigation_timeout", &self.default_navigation_timeout)
116            .finish_non_exhaustive()
117    }
118}
119
120/// Information about a page in the context.
121#[derive(Debug, Clone)]
122pub struct PageInfo {
123    /// Target ID.
124    pub target_id: String,
125    /// Session ID (may be empty if not tracked).
126    pub session_id: String,
127}
128
129impl BrowserContext {
130    /// Create a new browser context.
131    pub(crate) fn new(connection: Arc<CdpConnection>, context_id: String) -> Self {
132        debug!(context_id = %context_id, "Created BrowserContext");
133        let route_registry = Arc::new(routing::ContextRouteRegistry::new(
134            connection.clone(),
135            context_id.clone(),
136        ));
137        let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
138        let ctx = Self {
139            connection: connection.clone(),
140            context_id: context_id.clone(),
141            closed: false,
142            pages: Arc::new(RwLock::new(Vec::new())),
143            default_timeout: Duration::from_secs(30),
144            default_navigation_timeout: Duration::from_secs(30),
145            options: ContextOptions::default(),
146            weberror_handler: Arc::new(RwLock::new(None)),
147            event_manager: Arc::new(ContextEventManager::new()),
148            route_registry,
149            binding_registry,
150            init_scripts: Arc::new(RwLock::new(Vec::new())),
151            test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
152            har_recorder: Arc::new(RwLock::new(None)),
153            tracing_state: Arc::new(RwLock::new(TracingState::default())),
154        };
155        ctx.start_weberror_listener();
156        ctx
157    }
158
159    /// Create a new browser context with options.
160    pub(crate) fn with_options(
161        connection: Arc<CdpConnection>,
162        context_id: String,
163        options: ContextOptions,
164    ) -> Self {
165        debug!(context_id = %context_id, "Created BrowserContext with options");
166        let route_registry = Arc::new(routing::ContextRouteRegistry::new(
167            connection.clone(),
168            context_id.clone(),
169        ));
170        let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
171        let ctx = Self {
172            connection: connection.clone(),
173            context_id: context_id.clone(),
174            closed: false,
175            pages: Arc::new(RwLock::new(Vec::new())),
176            default_timeout: options.default_timeout.unwrap_or(Duration::from_secs(30)),
177            default_navigation_timeout: options
178                .default_navigation_timeout
179                .unwrap_or(Duration::from_secs(30)),
180            options,
181            weberror_handler: Arc::new(RwLock::new(None)),
182            event_manager: Arc::new(ContextEventManager::new()),
183            route_registry,
184            binding_registry,
185            init_scripts: Arc::new(RwLock::new(Vec::new())),
186            test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
187            har_recorder: Arc::new(RwLock::new(None)),
188            tracing_state: Arc::new(RwLock::new(TracingState::default())),
189        };
190        ctx.start_weberror_listener();
191        ctx
192    }
193
194    // Web error listener is started from weberror.rs
195
196    /// Apply initial options after context creation.
197    ///
198    /// This is called internally after the context is created to apply
199    /// settings like geolocation, permissions, etc.
200    pub(crate) async fn apply_options(&self) -> Result<(), ContextError> {
201        // Apply geolocation if set
202        if let Some(ref geo) = self.options.geolocation {
203            self.set_geolocation(geo.latitude, geo.longitude)
204                .accuracy(geo.accuracy)
205                .await?;
206        }
207
208        // Apply permissions if set
209        if !self.options.permissions.is_empty() {
210            self.grant_permissions(self.options.permissions.clone())
211                .await?;
212        }
213
214        // Apply extra headers if set
215        if !self.options.extra_http_headers.is_empty() {
216            self.set_extra_http_headers(self.options.extra_http_headers.clone())
217                .await?;
218        }
219
220        // Apply offline mode if set
221        if self.options.offline {
222            self.set_offline(true).await?;
223        }
224
225        Ok(())
226    }
227
228    // =========================================================================
229    // Page Management
230    // =========================================================================
231
232    /// Create a new page in this context.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if page creation fails.
237    #[instrument(level = "info", skip(self), fields(context_id = %self.context_id))]
238    pub async fn new_page(&self) -> Result<Page, ContextError> {
239        if self.closed {
240            return Err(ContextError::Closed);
241        }
242
243        info!("Creating new page");
244
245        // Create target and attach to it
246        let (create_result, attach_result) =
247            page_factory::create_and_attach_target(&self.connection, &self.context_id).await?;
248
249        let target_id = &create_result.target_id;
250        let session_id = &attach_result.session_id;
251
252        // Enable required CDP domains on the page
253        page_factory::enable_page_domains(&self.connection, session_id).await?;
254
255        // Apply emulation settings (viewport, touch, locale, etc.)
256        page_factory::apply_emulation_settings(&self.connection, session_id, &self.options).await?;
257
258        // Get the main frame ID
259        let frame_id = page_factory::get_main_frame_id(&self.connection, session_id).await?;
260
261        // Track the page
262        page_factory::track_page(
263            &self.pages,
264            create_result.target_id.clone(),
265            attach_result.session_id.clone(),
266        )
267        .await;
268
269        // Apply context-level init scripts to the new page
270        if let Err(e) = self.apply_init_scripts_to_session(session_id).await {
271            debug!("Failed to apply init scripts: {}", e);
272        }
273
274        info!(target_id = %target_id, session_id = %session_id, frame_id = %frame_id, "Page created successfully");
275
276        // Get the test ID attribute from context
277        let test_id_attr = self.test_id_attribute.read().await.clone();
278
279        // Convert context HTTP credentials to network auth credentials
280        let http_credentials = page_factory::convert_http_credentials(&self.options);
281
282        // Create page with or without video recording
283        let page = page_factory::create_page_instance(
284            self.connection.clone(),
285            create_result,
286            attach_result,
287            frame_id,
288            &self.options,
289            test_id_attr,
290            self.route_registry.clone(),
291            http_credentials,
292        )
293        .await;
294
295        // Enable Fetch domain if there are context-level routes
296        // This ensures requests are intercepted for context routes
297        if let Err(e) = page.enable_fetch_for_context_routes().await {
298            debug!("Failed to enable Fetch for context routes: {}", e);
299        }
300
301        // Emit page event to registered handlers
302        self.event_manager.emit_page(page.clone_internal()).await;
303
304        Ok(page)
305    }
306
307    /// Get all pages in this context.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if querying targets fails.
312    pub async fn pages(&self) -> Result<Vec<PageInfo>, ContextError> {
313        if self.closed {
314            return Err(ContextError::Closed);
315        }
316
317        let result: GetTargetsResult = self
318            .connection
319            .send_command("Target.getTargets", Some(GetTargetsParams::default()), None)
320            .await?;
321
322        let pages: Vec<PageInfo> = result
323            .target_infos
324            .into_iter()
325            .filter(|t| {
326                t.browser_context_id.as_deref() == Some(&self.context_id)
327                    && t.target_type == "page"
328            })
329            .map(|t| PageInfo {
330                target_id: t.target_id,
331                session_id: String::new(), // Would need to track sessions
332            })
333            .collect();
334
335        Ok(pages)
336    }
337
338    // Cookie methods are in cookies.rs
339
340    // Storage state methods are in storage.rs
341
342    // Permissions methods are in permissions.rs
343
344    // =========================================================================
345    // Geolocation
346    // =========================================================================
347
348    /// Set the geolocation.
349    ///
350    /// # Example
351    ///
352    /// ```no_run
353    /// use viewpoint_core::BrowserContext;
354    ///
355    /// # async fn example(context: &BrowserContext) -> Result<(), viewpoint_core::CoreError> {
356    /// // San Francisco
357    /// context.set_geolocation(37.7749, -122.4194).await?;
358    /// # Ok(())
359    /// # }
360    /// ```
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if setting geolocation fails.
365    pub fn set_geolocation(&self, latitude: f64, longitude: f64) -> SetGeolocationBuilder<'_> {
366        SetGeolocationBuilder::new(self, latitude, longitude)
367    }
368
369    // clear_geolocation, set_extra_http_headers, set_offline are in emulation.rs
370
371    // =========================================================================
372    // Timeout Configuration
373    // =========================================================================
374
375    /// Set the default timeout for actions.
376    ///
377    /// This timeout is used for actions like clicking, typing, etc.
378    pub fn set_default_timeout(&mut self, timeout: Duration) {
379        self.default_timeout = timeout;
380    }
381
382    /// Get the default timeout for actions.
383    pub fn default_timeout(&self) -> Duration {
384        self.default_timeout
385    }
386
387    /// Set the default navigation timeout.
388    ///
389    /// This timeout is used for navigation operations like goto, reload, etc.
390    pub fn set_default_navigation_timeout(&mut self, timeout: Duration) {
391        self.default_navigation_timeout = timeout;
392    }
393
394    /// Get the default navigation timeout.
395    pub fn default_navigation_timeout(&self) -> Duration {
396        self.default_navigation_timeout
397    }
398
399    // Init script methods are in scripts.rs
400
401    // =========================================================================
402    // Context Lifecycle
403    // =========================================================================
404
405    /// Close this browser context and all its pages.
406    ///
407    /// # Errors
408    ///
409    /// Returns an error if closing fails.
410    #[instrument(level = "info", skip(self), fields(context_id = %self.context_id))]
411    pub async fn close(&mut self) -> Result<(), ContextError> {
412        if self.closed {
413            debug!("Context already closed");
414            return Ok(());
415        }
416
417        info!("Closing browser context");
418
419        // Auto-save HAR if recording is active
420        if let Some(recorder) = self.har_recorder.write().await.take() {
421            if let Err(e) = recorder.save().await {
422                debug!("Failed to auto-save HAR on close: {}", e);
423            } else {
424                debug!(path = %recorder.path().display(), "Auto-saved HAR on close");
425            }
426        }
427
428        // Emit close event before cleanup
429        self.event_manager.emit_close().await;
430
431        self.connection
432            .send_command::<_, serde_json::Value>(
433                "Target.disposeBrowserContext",
434                Some(DisposeBrowserContextParams {
435                    browser_context_id: self.context_id.clone(),
436                }),
437                None,
438            )
439            .await?;
440
441        // Clear all event handlers
442        self.event_manager.clear().await;
443
444        self.closed = true;
445        info!("Browser context closed");
446        Ok(())
447    }
448
449    /// Get the context ID.
450    pub fn id(&self) -> &str {
451        &self.context_id
452    }
453
454    /// Check if this context has been closed.
455    pub fn is_closed(&self) -> bool {
456        self.closed
457    }
458
459    /// Get a reference to the CDP connection.
460    pub fn connection(&self) -> &Arc<CdpConnection> {
461        &self.connection
462    }
463
464    /// Get the context ID.
465    pub fn context_id(&self) -> &str {
466        &self.context_id
467    }
468
469    // Web error event methods are in weberror.rs (on_weberror, off_weberror)
470
471    // Page and close event methods are in page_events.rs (on_page, off_page, on_close, off_close, wait_for_page)
472
473    // Context-level routing methods are in routing_impl.rs
474
475    // HAR recording methods are in har.rs
476
477    // Exposed function methods are in binding.rs (expose_function, remove_exposed_function)
478
479    // API request context methods are in api.rs (request, sync_cookies_from_api)
480
481    // Tracing method is in tracing_access.rs
482
483    // Test ID attribute methods are in test_id.rs
484}
485
486// ClearCookiesBuilder is in cookies.rs
487// SetGeolocationBuilder is in emulation.rs