mod api;
pub mod binding;
mod cookies;
mod emulation;
pub mod events;
mod har;
mod page_events;
mod page_factory;
mod permissions;
pub mod routing;
mod routing_impl;
mod scripts;
pub mod storage;
mod storage_restore;
mod test_id;
pub mod trace;
mod tracing_access;
pub mod types;
mod weberror;
pub use cookies::ClearCookiesBuilder;
pub use emulation::SetGeolocationBuilder;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::{debug, info, instrument};
use viewpoint_cdp::CdpConnection;
use viewpoint_cdp::protocol::target_domain::{
DisposeBrowserContextParams, GetTargetsParams, GetTargetsResult,
};
use crate::error::ContextError;
use crate::page::Page;
pub use events::{ContextEventManager, HandlerId};
pub use storage::{StorageStateBuilder, StorageStateOptions};
use trace::TracingState;
pub use trace::{Tracing, TracingOptions};
pub use types::{
ColorScheme, ContextOptions, ContextOptionsBuilder, Cookie, ForcedColors, Geolocation,
HttpCredentials, IndexedDbDatabase, IndexedDbEntry, IndexedDbIndex, IndexedDbObjectStore,
LocalStorageEntry, Permission, ReducedMotion, SameSite, StorageOrigin, StorageState,
StorageStateSource, ViewportSize,
};
pub use weberror::WebErrorHandler;
pub use crate::page::page_error::WebError;
pub const DEFAULT_TEST_ID_ATTRIBUTE: &str = "data-testid";
pub struct BrowserContext {
connection: Arc<CdpConnection>,
context_id: String,
closed: bool,
owned: bool,
pages: Arc<RwLock<Vec<PageInfo>>>,
default_timeout: Duration,
default_navigation_timeout: Duration,
options: ContextOptions,
weberror_handler: Arc<RwLock<Option<WebErrorHandler>>>,
event_manager: Arc<ContextEventManager>,
route_registry: Arc<routing::ContextRouteRegistry>,
binding_registry: Arc<binding::ContextBindingRegistry>,
init_scripts: Arc<RwLock<Vec<String>>>,
test_id_attribute: Arc<RwLock<String>>,
har_recorder: Arc<RwLock<Option<crate::network::HarRecorder>>>,
tracing_state: Arc<RwLock<TracingState>>,
}
impl std::fmt::Debug for BrowserContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrowserContext")
.field("context_id", &self.context_id)
.field("closed", &self.closed)
.field("owned", &self.owned)
.field("default_timeout", &self.default_timeout)
.field(
"default_navigation_timeout",
&self.default_navigation_timeout,
)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct PageInfo {
pub target_id: String,
pub session_id: String,
}
impl BrowserContext {
pub(crate) fn new(connection: Arc<CdpConnection>, context_id: String) -> Self {
debug!(context_id = %context_id, "Created BrowserContext");
let route_registry = Arc::new(routing::ContextRouteRegistry::new(
connection.clone(),
context_id.clone(),
));
let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
let ctx = Self {
connection: connection.clone(),
context_id: context_id.clone(),
closed: false,
owned: true, pages: Arc::new(RwLock::new(Vec::new())),
default_timeout: Duration::from_secs(30),
default_navigation_timeout: Duration::from_secs(30),
options: ContextOptions::default(),
weberror_handler: Arc::new(RwLock::new(None)),
event_manager: Arc::new(ContextEventManager::new()),
route_registry,
binding_registry,
init_scripts: Arc::new(RwLock::new(Vec::new())),
test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
har_recorder: Arc::new(RwLock::new(None)),
tracing_state: Arc::new(RwLock::new(TracingState::default())),
};
ctx.start_weberror_listener();
ctx
}
pub(crate) fn with_options(
connection: Arc<CdpConnection>,
context_id: String,
options: ContextOptions,
) -> Self {
debug!(context_id = %context_id, "Created BrowserContext with options");
let route_registry = Arc::new(routing::ContextRouteRegistry::new(
connection.clone(),
context_id.clone(),
));
let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
let ctx = Self {
connection: connection.clone(),
context_id: context_id.clone(),
closed: false,
owned: true, pages: Arc::new(RwLock::new(Vec::new())),
default_timeout: options.default_timeout.unwrap_or(Duration::from_secs(30)),
default_navigation_timeout: options
.default_navigation_timeout
.unwrap_or(Duration::from_secs(30)),
options,
weberror_handler: Arc::new(RwLock::new(None)),
event_manager: Arc::new(ContextEventManager::new()),
route_registry,
binding_registry,
init_scripts: Arc::new(RwLock::new(Vec::new())),
test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
har_recorder: Arc::new(RwLock::new(None)),
tracing_state: Arc::new(RwLock::new(TracingState::default())),
};
ctx.start_weberror_listener();
ctx
}
pub(crate) fn from_existing(connection: Arc<CdpConnection>, context_id: String) -> Self {
let is_default = context_id.is_empty();
debug!(context_id = %context_id, is_default = is_default, "Wrapping existing BrowserContext");
let route_registry = Arc::new(routing::ContextRouteRegistry::new(
connection.clone(),
context_id.clone(),
));
let binding_registry = Arc::new(binding::ContextBindingRegistry::new());
let ctx = Self {
connection: connection.clone(),
context_id: context_id.clone(),
closed: false,
owned: false, pages: Arc::new(RwLock::new(Vec::new())),
default_timeout: Duration::from_secs(30),
default_navigation_timeout: Duration::from_secs(30),
options: ContextOptions::default(),
weberror_handler: Arc::new(RwLock::new(None)),
event_manager: Arc::new(ContextEventManager::new()),
route_registry,
binding_registry,
init_scripts: Arc::new(RwLock::new(Vec::new())),
test_id_attribute: Arc::new(RwLock::new(DEFAULT_TEST_ID_ATTRIBUTE.to_string())),
har_recorder: Arc::new(RwLock::new(None)),
tracing_state: Arc::new(RwLock::new(TracingState::default())),
};
ctx.start_weberror_listener();
ctx
}
pub(crate) async fn apply_options(&self) -> Result<(), ContextError> {
if let Some(ref geo) = self.options.geolocation {
self.set_geolocation(geo.latitude, geo.longitude)
.accuracy(geo.accuracy)
.await?;
}
if !self.options.permissions.is_empty() {
self.grant_permissions(self.options.permissions.clone())
.await?;
}
if !self.options.extra_http_headers.is_empty() {
self.set_extra_http_headers(self.options.extra_http_headers.clone())
.await?;
}
if self.options.offline {
self.set_offline(true).await?;
}
Ok(())
}
#[instrument(level = "info", skip(self), fields(context_id = %self.context_id))]
pub async fn new_page(&self) -> Result<Page, ContextError> {
if self.closed {
return Err(ContextError::Closed);
}
info!("Creating new page");
let (create_result, attach_result) =
page_factory::create_and_attach_target(&self.connection, &self.context_id).await?;
let target_id = &create_result.target_id;
let session_id = &attach_result.session_id;
page_factory::enable_page_domains(&self.connection, session_id).await?;
page_factory::apply_emulation_settings(&self.connection, session_id, &self.options).await?;
let frame_id = page_factory::get_main_frame_id(&self.connection, session_id).await?;
page_factory::track_page(
&self.pages,
create_result.target_id.clone(),
attach_result.session_id.clone(),
)
.await;
if let Err(e) = self.apply_init_scripts_to_session(session_id).await {
debug!("Failed to apply init scripts: {}", e);
}
info!(target_id = %target_id, session_id = %session_id, frame_id = %frame_id, "Page created successfully");
let test_id_attr = self.test_id_attribute.read().await.clone();
let http_credentials = page_factory::convert_http_credentials(&self.options);
let page = page_factory::create_page_instance(
self.connection.clone(),
create_result,
attach_result,
frame_id,
&self.options,
test_id_attr,
self.route_registry.clone(),
http_credentials,
)
.await;
if let Err(e) = page.enable_fetch_for_context_routes().await {
debug!("Failed to enable Fetch for context routes: {}", e);
}
self.event_manager.emit_page(page.clone_internal()).await;
Ok(page)
}
pub async fn pages(&self) -> Result<Vec<PageInfo>, ContextError> {
if self.closed {
return Err(ContextError::Closed);
}
let result: GetTargetsResult = self
.connection
.send_command("Target.getTargets", Some(GetTargetsParams::default()), None)
.await?;
let pages: Vec<PageInfo> = result
.target_infos
.into_iter()
.filter(|t| {
let matches_context = if self.context_id.is_empty() {
t.browser_context_id.as_deref().is_none()
|| t.browser_context_id.as_deref() == Some("")
} else {
t.browser_context_id.as_deref() == Some(&self.context_id)
};
matches_context && t.target_type == "page"
})
.map(|t| PageInfo {
target_id: t.target_id,
session_id: String::new(), })
.collect();
Ok(pages)
}
pub fn is_owned(&self) -> bool {
self.owned
}
pub fn is_default(&self) -> bool {
self.context_id.is_empty()
}
pub fn set_geolocation(&self, latitude: f64, longitude: f64) -> SetGeolocationBuilder<'_> {
SetGeolocationBuilder::new(self, latitude, longitude)
}
pub fn set_default_timeout(&mut self, timeout: Duration) {
self.default_timeout = timeout;
}
pub fn default_timeout(&self) -> Duration {
self.default_timeout
}
pub fn set_default_navigation_timeout(&mut self, timeout: Duration) {
self.default_navigation_timeout = timeout;
}
pub fn default_navigation_timeout(&self) -> Duration {
self.default_navigation_timeout
}
#[instrument(level = "info", skip(self), fields(context_id = %self.context_id, owned = self.owned))]
pub async fn close(&mut self) -> Result<(), ContextError> {
if self.closed {
debug!("Context already closed");
return Ok(());
}
info!("Closing browser context");
if let Some(recorder) = self.har_recorder.write().await.take() {
if let Err(e) = recorder.save().await {
debug!("Failed to auto-save HAR on close: {}", e);
} else {
debug!(path = %recorder.path().display(), "Auto-saved HAR on close");
}
}
self.event_manager.emit_close().await;
if self.owned && !self.context_id.is_empty() {
debug!("Disposing owned browser context");
self.connection
.send_command::<_, serde_json::Value>(
"Target.disposeBrowserContext",
Some(DisposeBrowserContextParams {
browser_context_id: self.context_id.clone(),
}),
None,
)
.await?;
} else {
debug!("Skipping dispose for external/default context");
}
self.event_manager.clear().await;
self.closed = true;
info!("Browser context closed");
Ok(())
}
pub fn id(&self) -> &str {
&self.context_id
}
pub fn is_closed(&self) -> bool {
self.closed
}
pub fn connection(&self) -> &Arc<CdpConnection> {
&self.connection
}
pub fn context_id(&self) -> &str {
&self.context_id
}
}