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;
17mod test_id;
18pub mod trace;
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::CdpConnection;
34use viewpoint_cdp::protocol::target_domain::{
35    DisposeBrowserContextParams, GetTargetsParams, GetTargetsResult,
36};
37
38use crate::error::ContextError;
39use crate::page::Page;
40
41pub use events::{ContextEventManager, HandlerId};
42pub use storage::{StorageStateBuilder, StorageStateOptions};
43use trace::TracingState;
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, StorageState,
49    StorageStateSource, ViewportSize,
50};
51pub use weberror::WebErrorHandler;
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
75///
76/// # Ownership
77///
78/// Contexts can be either "owned" (created by us) or "external" (discovered when
79/// connecting to an existing browser). When closing an external context, the
80/// underlying browser context is not disposed - only our connection to it is closed.
81pub struct BrowserContext {
82    /// CDP connection.
83    connection: Arc<CdpConnection>,
84    /// Browser context ID.
85    context_id: String,
86    /// Whether the context has been closed.
87    closed: bool,
88    /// Whether we own this context (created it) vs discovered it.
89    /// Owned contexts are disposed when closed; external contexts are not.
90    owned: bool,
91    /// Created pages (weak tracking for `pages()` method).
92    pages: Arc<RwLock<Vec<PageInfo>>>,
93    /// Default timeout for actions.
94    default_timeout: Duration,
95    /// Default timeout for navigation.
96    default_navigation_timeout: Duration,
97    /// Context options used to create this context.
98    options: ContextOptions,
99    /// Web error handler.
100    weberror_handler: Arc<RwLock<Option<WebErrorHandler>>>,
101    /// Event manager for context-level events.
102    event_manager: Arc<ContextEventManager>,
103    /// Context-level route registry.
104    route_registry: Arc<routing::ContextRouteRegistry>,
105    /// Context-level binding registry.
106    binding_registry: Arc<binding::ContextBindingRegistry>,
107    /// Init scripts to run on every page load.
108    init_scripts: Arc<RwLock<Vec<String>>>,
109    /// Custom test ID attribute name (defaults to "data-testid").
110    test_id_attribute: Arc<RwLock<String>>,
111    /// HAR recorder for capturing network traffic.
112    har_recorder: Arc<RwLock<Option<crate::network::HarRecorder>>>,
113    /// Shared tracing state for persistent tracing across `tracing()` calls.
114    tracing_state: Arc<RwLock<TracingState>>,
115}
116
117// Manual Debug implementation since WebErrorHandler doesn't implement Debug
118impl std::fmt::Debug for BrowserContext {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("BrowserContext")
121            .field("context_id", &self.context_id)
122            .field("closed", &self.closed)
123            .field("owned", &self.owned)
124            .field("default_timeout", &self.default_timeout)
125            .field(
126                "default_navigation_timeout",
127                &self.default_navigation_timeout,
128            )
129            .finish_non_exhaustive()
130    }
131}
132
133/// Information about a page in the context.
134#[derive(Debug, Clone)]
135pub struct PageInfo {
136    /// Target ID.
137    pub target_id: String,
138    /// Session ID (may be empty if not tracked).
139    pub session_id: String,
140}
141
142impl BrowserContext {
143    /// Create a new browser context.
144    pub(crate) fn new(connection: Arc<CdpConnection>, context_id: String) -> Self {
145        debug!(context_id = %context_id, "Created BrowserContext");
146        let route_registry = Arc::new(routing::ContextRouteRegistry::new(
147            connection.clone(),
148            context_id.clone(),
149        ));
150        let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
151        let ctx = Self {
152            connection: connection.clone(),
153            context_id: context_id.clone(),
154            closed: false,
155            owned: true, // We created this context
156            pages: Arc::new(RwLock::new(Vec::new())),
157            default_timeout: Duration::from_secs(30),
158            default_navigation_timeout: Duration::from_secs(30),
159            options: ContextOptions::default(),
160            weberror_handler: Arc::new(RwLock::new(None)),
161            event_manager: Arc::new(ContextEventManager::new()),
162            route_registry,
163            binding_registry,
164            init_scripts: Arc::new(RwLock::new(Vec::new())),
165            test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
166            har_recorder: Arc::new(RwLock::new(None)),
167            tracing_state: Arc::new(RwLock::new(TracingState::default())),
168        };
169        ctx.start_weberror_listener();
170        ctx
171    }
172
173    /// Create a new browser context with options.
174    pub(crate) fn with_options(
175        connection: Arc<CdpConnection>,
176        context_id: String,
177        options: ContextOptions,
178    ) -> Self {
179        debug!(context_id = %context_id, "Created BrowserContext with options");
180        let route_registry = Arc::new(routing::ContextRouteRegistry::new(
181            connection.clone(),
182            context_id.clone(),
183        ));
184        let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
185        let ctx = Self {
186            connection: connection.clone(),
187            context_id: context_id.clone(),
188            closed: false,
189            owned: true, // We created this context
190            pages: Arc::new(RwLock::new(Vec::new())),
191            default_timeout: options.default_timeout.unwrap_or(Duration::from_secs(30)),
192            default_navigation_timeout: options
193                .default_navigation_timeout
194                .unwrap_or(Duration::from_secs(30)),
195            options,
196            weberror_handler: Arc::new(RwLock::new(None)),
197            event_manager: Arc::new(ContextEventManager::new()),
198            route_registry,
199            binding_registry,
200            init_scripts: Arc::new(RwLock::new(Vec::new())),
201            test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
202            har_recorder: Arc::new(RwLock::new(None)),
203            tracing_state: Arc::new(RwLock::new(TracingState::default())),
204        };
205        ctx.start_weberror_listener();
206        ctx
207    }
208
209    /// Create a wrapper for an existing browser context.
210    ///
211    /// This is used when connecting to an already-running browser to wrap
212    /// contexts that existed before our connection. External contexts are
213    /// not disposed when closed - only our connection to them is closed.
214    pub(crate) fn from_existing(connection: Arc<CdpConnection>, context_id: String) -> Self {
215        let is_default = context_id.is_empty();
216        debug!(context_id = %context_id, is_default = is_default, "Wrapping existing BrowserContext");
217        let route_registry = Arc::new(routing::ContextRouteRegistry::new(
218            connection.clone(),
219            context_id.clone(),
220        ));
221        let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
222        let ctx = Self {
223            connection: connection.clone(),
224            context_id: context_id.clone(),
225            closed: false,
226            owned: false, // We didn't create this context
227            pages: Arc::new(RwLock::new(Vec::new())),
228            default_timeout: Duration::from_secs(30),
229            default_navigation_timeout: Duration::from_secs(30),
230            options: ContextOptions::default(),
231            weberror_handler: Arc::new(RwLock::new(None)),
232            event_manager: Arc::new(ContextEventManager::new()),
233            route_registry,
234            binding_registry,
235            init_scripts: Arc::new(RwLock::new(Vec::new())),
236            test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
237            har_recorder: Arc::new(RwLock::new(None)),
238            tracing_state: Arc::new(RwLock::new(TracingState::default())),
239        };
240        ctx.start_weberror_listener();
241        ctx
242    }
243
244    // Web error listener is started from weberror.rs
245
246    /// Apply initial options after context creation.
247    ///
248    /// This is called internally after the context is created to apply
249    /// settings like geolocation, permissions, etc.
250    pub(crate) async fn apply_options(&self) -> Result<(), ContextError> {
251        // Apply geolocation if set
252        if let Some(ref geo) = self.options.geolocation {
253            self.set_geolocation(geo.latitude, geo.longitude)
254                .accuracy(geo.accuracy)
255                .await?;
256        }
257
258        // Apply permissions if set
259        if !self.options.permissions.is_empty() {
260            self.grant_permissions(self.options.permissions.clone())
261                .await?;
262        }
263
264        // Apply extra headers if set
265        if !self.options.extra_http_headers.is_empty() {
266            self.set_extra_http_headers(self.options.extra_http_headers.clone())
267                .await?;
268        }
269
270        // Apply offline mode if set
271        if self.options.offline {
272            self.set_offline(true).await?;
273        }
274
275        Ok(())
276    }
277
278    // =========================================================================
279    // Page Management
280    // =========================================================================
281
282    /// Create a new page in this context.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if page creation fails.
287    #[instrument(level = "info", skip(self), fields(context_id = %self.context_id))]
288    pub async fn new_page(&self) -> Result<Page, ContextError> {
289        if self.closed {
290            return Err(ContextError::Closed);
291        }
292
293        info!("Creating new page");
294
295        // Create target and attach to it
296        let (create_result, attach_result) =
297            page_factory::create_and_attach_target(&self.connection, &self.context_id).await?;
298
299        let target_id = &create_result.target_id;
300        let session_id = &attach_result.session_id;
301
302        // Enable required CDP domains on the page
303        page_factory::enable_page_domains(&self.connection, session_id).await?;
304
305        // Apply emulation settings (viewport, touch, locale, etc.)
306        page_factory::apply_emulation_settings(&self.connection, session_id, &self.options).await?;
307
308        // Get the main frame ID
309        let frame_id = page_factory::get_main_frame_id(&self.connection, session_id).await?;
310
311        // Track the page
312        page_factory::track_page(
313            &self.pages,
314            create_result.target_id.clone(),
315            attach_result.session_id.clone(),
316        )
317        .await;
318
319        // Apply context-level init scripts to the new page
320        if let Err(e) = self.apply_init_scripts_to_session(session_id).await {
321            debug!("Failed to apply init scripts: {}", e);
322        }
323
324        info!(target_id = %target_id, session_id = %session_id, frame_id = %frame_id, "Page created successfully");
325
326        // Get the test ID attribute from context
327        let test_id_attr = self.test_id_attribute.read().await.clone();
328
329        // Convert context HTTP credentials to network auth credentials
330        let http_credentials = page_factory::convert_http_credentials(&self.options);
331
332        // Create page with or without video recording
333        let page = page_factory::create_page_instance(
334            self.connection.clone(),
335            create_result,
336            attach_result,
337            frame_id,
338            &self.options,
339            test_id_attr,
340            self.route_registry.clone(),
341            http_credentials,
342        )
343        .await;
344
345        // Enable Fetch domain if there are context-level routes
346        // This ensures requests are intercepted for context routes
347        if let Err(e) = page.enable_fetch_for_context_routes().await {
348            debug!("Failed to enable Fetch for context routes: {}", e);
349        }
350
351        // Emit page event to registered handlers
352        self.event_manager.emit_page(page.clone_internal()).await;
353
354        Ok(page)
355    }
356
357    /// Get all pages in this context.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if querying targets fails.
362    pub async fn pages(&self) -> Result<Vec<PageInfo>, ContextError> {
363        if self.closed {
364            return Err(ContextError::Closed);
365        }
366
367        let result: GetTargetsResult = self
368            .connection
369            .send_command("Target.getTargets", Some(GetTargetsParams::default()), None)
370            .await?;
371
372        let pages: Vec<PageInfo> = result
373            .target_infos
374            .into_iter()
375            .filter(|t| {
376                // For the default context (empty string ID), match targets with no context ID
377                // or with an empty context ID
378                let matches_context = if self.context_id.is_empty() {
379                    // Default context: match targets without a context ID or with empty context ID
380                    t.browser_context_id.as_deref().is_none()
381                        || t.browser_context_id.as_deref() == Some("")
382                } else {
383                    // Named context: exact match
384                    t.browser_context_id.as_deref() == Some(&self.context_id)
385                };
386                matches_context && t.target_type == "page"
387            })
388            .map(|t| PageInfo {
389                target_id: t.target_id,
390                session_id: String::new(), // Would need to track sessions
391            })
392            .collect();
393
394        Ok(pages)
395    }
396
397    /// Check if this context is owned (created by us) or external.
398    ///
399    /// External contexts are discovered when connecting to an already-running browser.
400    /// They are not disposed when closed.
401    pub fn is_owned(&self) -> bool {
402        self.owned
403    }
404
405    /// Check if this is the default browser context.
406    ///
407    /// The default context represents the browser's main profile and has an empty ID.
408    pub fn is_default(&self) -> bool {
409        self.context_id.is_empty()
410    }
411
412    // Cookie methods are in cookies.rs
413
414    // Storage state methods are in storage.rs
415
416    // Permissions methods are in permissions.rs
417
418    // =========================================================================
419    // Geolocation
420    // =========================================================================
421
422    /// Set the geolocation.
423    ///
424    /// # Example
425    ///
426    /// ```no_run
427    /// use viewpoint_core::BrowserContext;
428    ///
429    /// # async fn example(context: &BrowserContext) -> Result<(), viewpoint_core::CoreError> {
430    /// // San Francisco
431    /// context.set_geolocation(37.7749, -122.4194).await?;
432    /// # Ok(())
433    /// # }
434    /// ```
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if setting geolocation fails.
439    pub fn set_geolocation(&self, latitude: f64, longitude: f64) -> SetGeolocationBuilder<'_> {
440        SetGeolocationBuilder::new(self, latitude, longitude)
441    }
442
443    // clear_geolocation, set_extra_http_headers, set_offline are in emulation.rs
444
445    // =========================================================================
446    // Timeout Configuration
447    // =========================================================================
448
449    /// Set the default timeout for actions.
450    ///
451    /// This timeout is used for actions like clicking, typing, etc.
452    pub fn set_default_timeout(&mut self, timeout: Duration) {
453        self.default_timeout = timeout;
454    }
455
456    /// Get the default timeout for actions.
457    pub fn default_timeout(&self) -> Duration {
458        self.default_timeout
459    }
460
461    /// Set the default navigation timeout.
462    ///
463    /// This timeout is used for navigation operations like goto, reload, etc.
464    pub fn set_default_navigation_timeout(&mut self, timeout: Duration) {
465        self.default_navigation_timeout = timeout;
466    }
467
468    /// Get the default navigation timeout.
469    pub fn default_navigation_timeout(&self) -> Duration {
470        self.default_navigation_timeout
471    }
472
473    // Init script methods are in scripts.rs
474
475    // =========================================================================
476    // Context Lifecycle
477    // =========================================================================
478
479    /// Close this browser context and all its pages.
480    ///
481    /// For contexts we created (owned), this disposes the context via CDP.
482    /// For external contexts (discovered when connecting to an existing browser),
483    /// this only closes our connection without disposing the context.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if closing fails.
488    #[instrument(level = "info", skip(self), fields(context_id = %self.context_id, owned = self.owned))]
489    pub async fn close(&mut self) -> Result<(), ContextError> {
490        if self.closed {
491            debug!("Context already closed");
492            return Ok(());
493        }
494
495        info!("Closing browser context");
496
497        // Auto-save HAR if recording is active
498        if let Some(recorder) = self.har_recorder.write().await.take() {
499            if let Err(e) = recorder.save().await {
500                debug!("Failed to auto-save HAR on close: {}", e);
501            } else {
502                debug!(path = %recorder.path().display(), "Auto-saved HAR on close");
503            }
504        }
505
506        // Emit close event before cleanup
507        self.event_manager.emit_close().await;
508
509        // Only dispose the context if we own it
510        // External contexts (from connecting to existing browser) should not be disposed
511        if self.owned && !self.context_id.is_empty() {
512            debug!("Disposing owned browser context");
513            self.connection
514                .send_command::<_, serde_json::Value>(
515                    "Target.disposeBrowserContext",
516                    Some(DisposeBrowserContextParams {
517                        browser_context_id: self.context_id.clone(),
518                    }),
519                    None,
520                )
521                .await?;
522        } else {
523            debug!("Skipping dispose for external/default context");
524        }
525
526        // Clear all event handlers
527        self.event_manager.clear().await;
528
529        self.closed = true;
530        info!("Browser context closed");
531        Ok(())
532    }
533
534    /// Get the context ID.
535    pub fn id(&self) -> &str {
536        &self.context_id
537    }
538
539    /// Check if this context has been closed.
540    pub fn is_closed(&self) -> bool {
541        self.closed
542    }
543
544    /// Get a reference to the CDP connection.
545    pub fn connection(&self) -> &Arc<CdpConnection> {
546        &self.connection
547    }
548
549    /// Get the context ID.
550    pub fn context_id(&self) -> &str {
551        &self.context_id
552    }
553
554    // Web error event methods are in weberror.rs (on_weberror, off_weberror)
555
556    // Page and close event methods are in page_events.rs (on_page, off_page, on_close, off_close, wait_for_page)
557
558    // Context-level routing methods are in routing_impl.rs
559
560    // HAR recording methods are in har.rs
561
562    // Exposed function methods are in binding.rs (expose_function, remove_exposed_function)
563
564    // API request context methods are in api.rs (request, sync_cookies_from_api)
565
566    // Tracing method is in tracing_access.rs
567
568    // Test ID attribute methods are in test_id.rs
569}
570
571// ClearCookiesBuilder is in cookies.rs
572// SetGeolocationBuilder is in emulation.rs