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