Files
moli/moli-page-types/src/lib.rs
T

4566 lines
146 KiB
Rust

//! Shared page and protocol value types for Moli.
//!
//! This crate holds renderer-neutral data structures such as navigation
//! responses, script execution reports, subresource records, and frame
//! snapshots that are shared by the facade, renderer, and CDP crates.
mod document_activity;
mod inspector_identity;
mod inspector_state;
mod layout;
mod navigation_history;
mod navigator_overrides;
mod renderer_transport_memory;
mod session_history;
pub use session_history::{SessionHistoryCommit, SessionHistorySeed, SessionHistoryUpdate};
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
io::{self, Write},
sync::Arc,
};
use moli_disk_pool::{DiskData, DiskPool};
use moli_parkable_image::ParkableImage;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
use http_auth::parser::ChallengeParser;
use moli_cookie_jar::{StoredCookieQueryReport, StoredCookieSetReport};
use moli_dom::NodeId;
use moli_fetch::{
NegotiatedHttpVersion, NetworkRequestExtraInfo, NetworkResponseExtraInfo, RedirectInfo,
RequestAuth, RequestAuthScheme, RequestAuthTarget, Response, ResponseBody, ResponseHead,
};
use moli_web_mime::is_json_module_mime;
const SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT: usize = 1024 * 1024;
pub use document_activity::DocumentActivity;
pub use inspector_identity::{
DevToolsSessionKey, FrontendCommandId, RendererAgentAttachmentId, RendererCallId,
RendererCallIdOutOfRange, RendererDevToolsAgentToken, RendererDevToolsCommandId,
RendererInspectorResponseDelivery,
};
pub use layout::LayoutPolicy;
pub use navigator_overrides::{
GeolocationPositionOverride, NavigatorEmulationSessions, NavigatorOverrides,
NavigatorQueryOverrides,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentNodeAttributeSnapshot {
pub local_name: String,
pub value: String,
}
/// Snapshot-local DOM storage id used inside one captured document payload.
pub type DocumentSnapshotNodeId = NodeId;
/// Stable renderer identity for a node that exists only in the inspector
/// projection and therefore has no independent DOM storage handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DocumentNodeInspectorIdentity {
MarkerPseudoElement,
UserAgentShadowTreeNode {
tree_kind: u16,
ordinal: u16,
state: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocumentNodeAssociatedSnapshot {
TemplateContent(DocumentNodeSnapshot),
ContentDocument(DocumentNodeSnapshot),
}
impl DocumentNodeAssociatedSnapshot {
pub fn node(&self) -> &DocumentNodeSnapshot {
match self {
Self::TemplateContent(node) | Self::ContentDocument(node) => node,
}
}
pub fn node_mut(&mut self) -> &mut DocumentNodeSnapshot {
match self {
Self::TemplateContent(node) | Self::ContentDocument(node) => node,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentNodeSnapshot {
pub node_id: DocumentSnapshotNodeId,
pub parent_id: Option<DocumentSnapshotNodeId>,
pub inspector_identity: Option<DocumentNodeInspectorIdentity>,
pub inspector_parent_identity: Option<DocumentNodeInspectorIdentity>,
pub frontend_node_id: Option<u32>,
pub parent_frontend_node_id: Option<u32>,
pub backend_node_id: Option<u32>,
pub frame_id: Option<String>,
pub node_type: u8,
pub node_name: String,
pub local_name: String,
pub node_value: String,
pub child_count: usize,
pub document_url: String,
pub base_url: String,
pub namespace_uri: Option<String>,
pub attributes: Vec<DocumentNodeAttributeSnapshot>,
pub document_type_name: Option<String>,
pub public_id: Option<String>,
pub system_id: Option<String>,
pub is_element: bool,
pub has_geometry: bool,
pub shadow_root_type: Option<String>,
pub shadow_roots: Vec<DocumentNodeSnapshot>,
/// CDP pseudo-element type for an inspector-only node such as `marker`.
/// The originating element remains the snapshot identity owner.
pub pseudo_type: Option<String>,
pub pseudo_elements: Vec<DocumentNodeSnapshot>,
/// A non-child node associated with this DOM node. An HTML template owns a
/// shallow template-content fragment; an iframe/frame owner can expose its
/// current content document. Those host categories are mutually exclusive.
pub associated: Option<Box<DocumentNodeAssociatedSnapshot>>,
pub children: Vec<DocumentNodeSnapshot>,
}
impl DocumentNodeSnapshot {
pub fn associated_node(&self) -> Option<&Self> {
self.associated
.as_deref()
.map(|associated| associated.node())
}
pub fn associated_node_mut(&mut self) -> Option<&mut Self> {
self.associated
.as_deref_mut()
.map(|associated| associated.node_mut())
}
pub fn template_content(&self) -> Option<&Self> {
match self.associated.as_deref() {
Some(DocumentNodeAssociatedSnapshot::TemplateContent(content)) => Some(content),
_ => None,
}
}
pub fn content_document(&self) -> Option<&Self> {
match self.associated.as_deref() {
Some(DocumentNodeAssociatedSnapshot::ContentDocument(document)) => Some(document),
_ => None,
}
}
}
pub const RENDERER_BACKEND_NODE_ID_START: u32 = 2_000_000_000;
// Chromium's V8 inspector caps protocol value construction at
// kMaxProtocolDepth = 1000. Blink's DOM deep serialization keeps DOM node
// expansion as a separate maxNodeDepth concern. Keep the same split here:
// protocol/BiDi depth parameters retain their protocol meaning, while these
// implementation caps stop Rust output builders from recursing without bound.
pub const MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH: usize = 1000;
pub const MAX_DOM_OUTPUT_TREE_DEPTH: usize = MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH;
pub const MAX_JSON_OUTPUT_TREE_DEPTH: usize = MAX_INSPECTOR_PROTOCOL_VALUE_DEPTH;
pub const fn is_renderer_backend_node_id(id: u32) -> bool {
id >= RENDERER_BACKEND_NODE_ID_START
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentNodeObjectSnapshot {
pub frame_id: Option<String>,
pub owner_node_id: Option<DocumentSnapshotNodeId>,
pub node_path: Option<Vec<usize>>,
pub snapshot: DocumentNodeSnapshot,
}
pub use inspector_state::{
RendererDomDebuggerDomBreakpointType, RendererDomDebuggerEventListenerBreakpoint,
RendererDomDebuggerXhrBreakpoint, RendererInspectorProtocolConfiguration,
RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot,
V8InspectorSessionAttach, V8InspectorSessionState,
renderer_inspector_protocol_configuration_command_from_message,
renderer_inspector_protocol_configuration_command_from_method,
};
pub use navigation_history::{
NavigationActivationSeed, NavigationHistoryEntryId, NavigationHistoryEntrySeed,
NavigationHistoryMutation, NavigationHistorySerializedEntry, NavigationTraversalSeedCandidate,
SessionHistoryUpdateKind, apply_child_browsing_context_javascript_url_navigation_to_entry_seed,
apply_child_browsing_context_navigation_to_entry_seed,
child_browsing_context_single_entry_seed, cross_document_navigation_seed,
initial_navigation_history_seed, reload_navigation_seed,
replace_child_browsing_context_navigation_in_entry_seed, traversal_navigation_seed_candidate,
};
/// A rectangle relative to a DOM node that should be exposed by a
/// scroll-into-view operation.
///
/// CDP's `DOM.scrollIntoViewIfNeeded` accepts this optional payload. Keeping
/// it in the shared page types lets the protocol pass the request to the
/// renderer without erasing it into JSON or reinterpreting it in the browser
/// process.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DomScrollIntoViewRect {
x: f64,
y: f64,
width: f64,
height: f64,
}
impl DomScrollIntoViewRect {
/// Creates a relative scroll rectangle when every component is a finite
/// protocol number.
///
/// Keeping the fields private makes this invariant shared by every command
/// frontend and prevents non-finite coordinates from reaching renderer
/// geometry.
pub fn try_new(x: f64, y: f64, width: f64, height: f64) -> Option<Self> {
[x, y, width, height]
.into_iter()
.all(f64::is_finite)
.then_some(Self {
x,
y,
width,
height,
})
}
pub fn x(self) -> f64 {
self.x
}
pub fn y(self) -> f64 {
self.y
}
pub fn width(self) -> f64 {
self.width
}
pub fn height(self) -> f64 {
self.height
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationRedirect {
pub source: moli_fetch::RedirectSource,
pub from_url: Url,
pub to_url: Url,
pub status: u16,
pub headers: Vec<(String, Vec<u8>)>,
pub network_extra_info_available: bool,
pub request_extra_info: Option<NetworkRequestExtraInfo>,
pub response_extra_info: Option<NetworkResponseExtraInfo>,
pub redirect_has_extra_info: bool,
pub request_cookie_report: Option<StoredCookieQueryReport>,
pub cookie_set_reports: Vec<StoredCookieSetReport>,
pub from_cache: bool,
pub negotiated_http_version: Option<NegotiatedHttpVersion>,
}
impl From<RedirectInfo> for NavigationRedirect {
fn from(value: RedirectInfo) -> Self {
Self {
source: value.source,
from_url: value.from_url,
to_url: value.to_url,
status: value.status,
headers: value.headers,
network_extra_info_available: value.network_extra_info_available,
request_extra_info: value.request_extra_info,
response_extra_info: value.response_extra_info,
redirect_has_extra_info: value.redirect_has_extra_info,
request_cookie_report: value.request_cookie_report,
cookie_set_reports: value.cookie_set_reports,
from_cache: value.from_cache,
negotiated_http_version: value.negotiated_http_version,
}
}
}
impl From<NavigationRedirect> for RedirectInfo {
fn from(value: NavigationRedirect) -> Self {
Self {
source: value.source,
from_url: value.from_url,
to_url: value.to_url,
status: value.status,
headers: value.headers,
network_extra_info_available: value.network_extra_info_available,
request_extra_info: value.request_extra_info,
response_extra_info: value.response_extra_info,
redirect_has_extra_info: value.redirect_has_extra_info,
request_cookie_report: value.request_cookie_report,
cookie_set_reports: value.cookie_set_reports,
from_cache: value.from_cache,
negotiated_http_version: value.negotiated_http_version,
}
}
}
#[derive(Debug)]
pub struct NavigationResponse {
pub final_url: Url,
pub status: u16,
pub headers: Vec<(String, Vec<u8>)>,
body: ResponseBody,
pub request_cookie_report: Option<StoredCookieQueryReport>,
pub cookie_set_reports: Vec<StoredCookieSetReport>,
pub redirected: bool,
pub redirect_chain: Vec<NavigationRedirect>,
pub from_cache: bool,
pub negotiated_http_version: Option<NegotiatedHttpVersion>,
network_request_headers: Option<Vec<(String, String)>>,
}
impl Clone for NavigationResponse {
fn clone(&self) -> Self {
Self {
final_url: self.final_url.clone(),
status: self.status,
headers: self.headers.clone(),
body: self
.body
.clone_materialized()
.expect("NavigationResponse body should remain materialized"),
request_cookie_report: self.request_cookie_report.clone(),
cookie_set_reports: self.cookie_set_reports.clone(),
redirected: self.redirected,
redirect_chain: self.redirect_chain.clone(),
from_cache: self.from_cache,
negotiated_http_version: self.negotiated_http_version,
network_request_headers: self.network_request_headers.clone(),
}
}
}
impl NavigationResponse {
pub fn body_text(&self) -> &str {
self.body
.as_materialized_text()
.expect("NavigationResponse body should remain materialized text")
}
pub fn body_bytes(&self) -> &[u8] {
self.body
.as_materialized_bytes()
.expect("NavigationResponse body should remain materialized")
}
/// Clones the complete materialized byte payload for compatibility callers
/// whose public contract requires an owned full-body buffer.
pub fn clone_body_bytes(&self) -> Vec<u8> {
self.body_bytes().to_vec()
}
/// Moves the complete materialized payload out of this response.
///
/// The response head remains usable and the response body becomes empty.
/// This is intended for handing large payloads to an owning backing store
/// without briefly retaining both the response buffer and a clone.
pub fn take_body_bytes(&mut self) -> Vec<u8> {
std::mem::replace(
&mut self.body,
ResponseBody::materialized_text(String::new(), Vec::new()),
)
.try_into_materialized_bytes()
.expect("NavigationResponse body should remain materialized")
}
pub fn materialized_body(&self) -> ResponseBody {
self.body
.clone_materialized()
.expect("NavigationResponse body should remain materialized")
}
pub fn head(&self) -> ResponseHead {
ResponseHead {
final_url: self.final_url.clone(),
status: self.status,
headers: self.headers.clone(),
request_cookie_report: self.request_cookie_report.clone(),
cookie_set_reports: self.cookie_set_reports.clone(),
redirected: self.redirected,
redirect_chain: self
.redirect_chain
.clone()
.into_iter()
.map(Into::into)
.collect(),
from_cache: self.from_cache,
negotiated_http_version: self.negotiated_http_version,
}
}
pub fn from_head_and_body(head: ResponseHead, body: String, body_bytes: Vec<u8>) -> Self {
Self::from_head_and_materialized_body(
head,
ResponseBody::materialized_text(body, body_bytes),
)
}
/// Headers configured on the HTTP transfer that produced this response.
/// `None` distinguishes local, cached, and synthetic responses from a
/// response observed at the network transport boundary.
pub fn with_network_request_headers(
mut self,
network_request_headers: Option<Vec<(String, String)>>,
) -> Self {
self.network_request_headers = network_request_headers;
self
}
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
self.network_request_headers.as_deref()
}
pub fn from_head_and_materialized_body(head: ResponseHead, body: ResponseBody) -> Self {
let body = body
.try_into_materialized_text_body()
.expect("NavigationResponse body should remain materialized text");
Self {
final_url: head.final_url,
status: head.status,
headers: head.headers,
body,
request_cookie_report: head.request_cookie_report,
cookie_set_reports: head.cookie_set_reports,
redirected: head.redirected,
redirect_chain: head.redirect_chain.into_iter().map(Into::into).collect(),
from_cache: head.from_cache,
negotiated_http_version: head.negotiated_http_version,
network_request_headers: None,
}
}
pub fn from_head_and_text_body(head: ResponseHead, body: String) -> Self {
Self::from_head_and_body(head, body, Vec::new())
}
pub fn from_text_body(
final_url: Url,
status: u16,
headers: Vec<(String, Vec<u8>)>,
body: String,
) -> Self {
Self::from_head_and_text_body(
ResponseHead {
final_url,
status,
headers,
request_cookie_report: None,
cookie_set_reports: Vec::new(),
redirected: false,
redirect_chain: Vec::new(),
from_cache: false,
negotiated_http_version: None,
},
body,
)
}
pub fn with_status_headers_from(
source: &Self,
status: u16,
headers: Vec<(String, Vec<u8>)>,
) -> Self {
let mut head = source.head();
head.status = status;
head.headers = headers;
Self::from_head_and_materialized_body(head, source.materialized_body())
.with_network_request_headers(source.network_request_headers.clone())
}
pub fn into_parts(self) -> (ResponseHead, String, Vec<u8>) {
let head = ResponseHead {
final_url: self.final_url,
status: self.status,
headers: self.headers,
request_cookie_report: self.request_cookie_report,
cookie_set_reports: self.cookie_set_reports,
redirected: self.redirected,
redirect_chain: self.redirect_chain.into_iter().map(Into::into).collect(),
from_cache: self.from_cache,
negotiated_http_version: self.negotiated_http_version,
};
let (body, body_bytes) = self
.body
.try_into_lossy_materialized_text()
.expect("NavigationResponse body should remain materialized text");
(head, body, body_bytes)
}
/// Consumes a materialized navigation response when the next renderer step
/// only accepts text and intentionally discards the exact byte payload.
pub fn into_text_parts(self) -> (ResponseHead, String) {
let (head, body, _) = self.into_parts();
(head, body)
}
/// Consumes a materialized response and transfers its exact byte payload
/// without retaining the text view or copying UTF-8 text storage.
pub fn into_byte_parts(self) -> (ResponseHead, Vec<u8>) {
let (head, body) = self.into_body();
let bytes = body
.try_into_materialized_bytes()
.expect("NavigationResponse body should remain materialized");
(head, bytes)
}
pub fn into_body(self) -> (ResponseHead, ResponseBody) {
let head = ResponseHead {
final_url: self.final_url,
status: self.status,
headers: self.headers,
request_cookie_report: self.request_cookie_report,
cookie_set_reports: self.cookie_set_reports,
redirected: self.redirected,
redirect_chain: self.redirect_chain.into_iter().map(Into::into).collect(),
from_cache: self.from_cache,
negotiated_http_version: self.negotiated_http_version,
};
(head, self.body)
}
}
impl From<Response> for NavigationResponse {
fn from(value: Response) -> Self {
let network_request_headers = value
.network_request_extra_info()
.map(|extra_info| extra_info.headers.clone());
let (head, body) = value.into_body();
Self::from_head_and_materialized_body(head, body)
.with_network_request_headers(network_request_headers)
}
}
impl From<NavigationResponse> for Response {
fn from(value: NavigationResponse) -> Self {
let network_request_headers = value.network_request_headers.clone();
let request_cookie_report = value.request_cookie_report.clone().unwrap_or_default();
let (head, body) = value.into_body();
Self::from_head_and_materialized_body(head, body)
.expect("NavigationResponse body should remain materialized at Response boundary")
.with_network_request_extra_info(network_request_headers.map(|headers| {
NetworkRequestExtraInfo {
headers,
cookie_report: request_cookie_report,
}
}))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ScriptGlobalsSnapshotState {
#[default]
Uncaptured,
Fresh,
Dirty,
}
#[derive(Debug, Clone, Default)]
pub struct ScriptExecutionReport {
pub runs: Vec<ScriptRun>,
globals: Arc<BTreeMap<String, JsValueSnapshot>>,
globals_snapshot_state: ScriptGlobalsSnapshotState,
observable_output_items: Vec<ScriptObservableOutputItem>,
console_messages: Vec<String>,
lifecycle_errors: Vec<String>,
inspector_issues: Vec<InspectorIssueSnapshot>,
subresource_network_records: Vec<SubresourceNetworkRecord>,
staged_subresource_lifecycle: Option<Box<StagedSubresourceReportState>>,
websocket_network_events: Vec<WebSocketNetworkEvent>,
websocket_lifecycle_events: Vec<WebSocketLifecycleEvent>,
}
#[derive(Debug, Clone, Default)]
struct StagedSubresourceReportState {
requests: BTreeMap<u64, SubresourceRequestStarted>,
responses: BTreeMap<u64, SubresourceResponseStarted>,
bodies: BTreeMap<u64, SubresourceBodyFinished>,
completed_handles: BTreeSet<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScriptNetworkOutput {
items: Vec<ScriptNetworkOutputItem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptNetworkOutputItem {
SubresourceNetworkRecord(Box<SubresourceNetworkRecord>),
SubresourceRequestStarted(Box<SubresourceRequestStarted>),
SubresourceResponseStarted(Box<SubresourceResponseStarted>),
SubresourceDataReceived(SubresourceDataReceived),
SubresourceEventSourceMessageReceived(Box<SubresourceEventSourceMessageReceived>),
SubresourceBodyFinished(Box<SubresourceBodyFinished>),
WebSocketNetworkEvent(WebSocketNetworkEvent),
WebSocketLifecycleEvent(WebSocketLifecycleEvent),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScriptObservableOutput {
items: Vec<ScriptObservableOutputItem>,
console_messages: Vec<String>,
lifecycle_errors: Vec<String>,
inspector_issues: Vec<InspectorIssueSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptObservableOutputItem {
ConsoleMessage(String),
LifecycleError(String),
InspectorIssue(Box<InspectorIssueSnapshot>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InspectorIssueSnapshot {
QuirksMode(QuirksModeIssueSnapshot),
ContentSecurityPolicy(ContentSecurityPolicyIssueSnapshot),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuirksModeIssueSnapshot {
is_limited_quirks_mode: bool,
document_node_id: u32,
url: String,
}
impl QuirksModeIssueSnapshot {
pub fn new(is_limited_quirks_mode: bool, document_node_id: u32, url: String) -> Self {
Self {
is_limited_quirks_mode,
document_node_id,
url,
}
}
pub fn is_limited_quirks_mode(&self) -> bool {
self.is_limited_quirks_mode
}
pub fn document_node_id(&self) -> u32 {
self.document_node_id
}
pub fn url(&self) -> &str {
&self.url
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentSecurityPolicyViolationType {
Eval,
WasmEval,
Inline,
TrustedTypesPolicy,
TrustedTypesSink,
Url,
SubresourceIntegrity,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectorSourceCodeLocationSnapshot {
url: String,
line_number: u32,
column_number: u32,
}
impl InspectorSourceCodeLocationSnapshot {
pub fn new(url: String, line_number: u32, column_number: u32) -> Self {
Self {
url,
line_number,
column_number,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn line_number(&self) -> u32 {
self.line_number
}
pub fn column_number(&self) -> u32 {
self.column_number
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentSecurityPolicyIssueSnapshot {
is_report_only: bool,
violated_directive: String,
violation_type: ContentSecurityPolicyViolationType,
blocked_url: Option<String>,
source_code_location: Option<InspectorSourceCodeLocationSnapshot>,
violating_node_id: Option<u32>,
}
impl ContentSecurityPolicyIssueSnapshot {
pub fn new(
is_report_only: bool,
violated_directive: String,
violation_type: ContentSecurityPolicyViolationType,
) -> Self {
Self {
is_report_only,
violated_directive,
violation_type,
blocked_url: None,
source_code_location: None,
violating_node_id: None,
}
}
pub fn with_blocked_url(mut self, blocked_url: Option<String>) -> Self {
self.blocked_url = blocked_url;
self
}
pub fn with_source_code_location(
mut self,
source_code_location: Option<InspectorSourceCodeLocationSnapshot>,
) -> Self {
self.source_code_location = source_code_location;
self
}
pub fn with_violating_node_id(mut self, violating_node_id: Option<u32>) -> Self {
self.violating_node_id = violating_node_id;
self
}
pub fn is_report_only(&self) -> bool {
self.is_report_only
}
pub fn violated_directive(&self) -> &str {
&self.violated_directive
}
pub fn violation_type(&self) -> ContentSecurityPolicyViolationType {
self.violation_type
}
pub fn blocked_url(&self) -> Option<&str> {
self.blocked_url.as_deref()
}
pub fn source_code_location(&self) -> Option<&InspectorSourceCodeLocationSnapshot> {
self.source_code_location.as_ref()
}
pub fn violating_node_id(&self) -> Option<u32> {
self.violating_node_id
}
}
impl ScriptNetworkOutput {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn from_items(items: impl IntoIterator<Item = ScriptNetworkOutputItem>) -> Self {
let mut output = Self::default();
for item in items {
output.push_item(item);
}
output
}
pub fn into_items(self) -> impl Iterator<Item = ScriptNetworkOutputItem> {
self.items.into_iter()
}
pub fn push_item(&mut self, item: ScriptNetworkOutputItem) {
self.items.push(item);
}
}
impl ScriptObservableOutput {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn from_items(items: impl IntoIterator<Item = ScriptObservableOutputItem>) -> Self {
let mut output = Self::default();
for item in items {
output.push_item(item);
}
output
}
pub fn into_items(self) -> impl Iterator<Item = ScriptObservableOutputItem> {
self.items.into_iter()
}
pub fn push_item(&mut self, item: ScriptObservableOutputItem) {
self.items.push(item.clone());
match item {
ScriptObservableOutputItem::ConsoleMessage(message) => {
self.console_messages.push(message);
}
ScriptObservableOutputItem::LifecycleError(error) => {
self.lifecycle_errors.push(error);
}
ScriptObservableOutputItem::InspectorIssue(issue) => {
self.inspector_issues.push(*issue);
}
}
}
}
impl ScriptExecutionReport {
pub fn runs(&self) -> &[ScriptRun] {
&self.runs
}
pub fn globals(&self) -> &BTreeMap<String, JsValueSnapshot> {
&self.globals
}
pub fn global(&self, name: &str) -> Option<&JsValueSnapshot> {
self.globals.get(name)
}
/// Whether [`Self::globals`] reflects the current JavaScript realm.
///
/// [`ScriptGlobalsSnapshotState::Uncaptured`] means the realm was created
/// without the opt-in diagnostics needed to distinguish browser-provided
/// globals from page-created globals. Protocol turns deliberately avoid
/// refreshing an enabled snapshot. While this is
/// [`ScriptGlobalsSnapshotState::Dirty`], `globals()` and `global()` expose
/// the last complete snapshot for backward compatibility; callers that
/// require current values must first request an asynchronous full
/// page-state refresh.
pub fn globals_snapshot_state(&self) -> ScriptGlobalsSnapshotState {
self.globals_snapshot_state
}
pub fn globals_are_fresh(&self) -> bool {
self.globals_snapshot_state == ScriptGlobalsSnapshotState::Fresh
}
pub fn fresh_globals(&self) -> Option<&BTreeMap<String, JsValueSnapshot>> {
self.globals_are_fresh().then_some(self.globals())
}
pub fn mark_globals_snapshot_dirty(&mut self) -> bool {
match self.globals_snapshot_state {
ScriptGlobalsSnapshotState::Fresh => {
self.globals_snapshot_state = ScriptGlobalsSnapshotState::Dirty;
true
}
ScriptGlobalsSnapshotState::Uncaptured | ScriptGlobalsSnapshotState::Dirty => false,
}
}
pub fn replace_globals_snapshot(&mut self, globals: BTreeMap<String, JsValueSnapshot>) -> bool {
if self.globals_snapshot_state == ScriptGlobalsSnapshotState::Fresh
&& self.globals.as_ref() == &globals
{
return false;
}
self.globals = Arc::new(globals);
self.globals_snapshot_state = ScriptGlobalsSnapshotState::Fresh;
true
}
pub fn console_messages(&self) -> &[String] {
&self.console_messages
}
pub fn lifecycle_errors(&self) -> &[String] {
&self.lifecycle_errors
}
pub fn inspector_issues(&self) -> &[InspectorIssueSnapshot] {
&self.inspector_issues
}
pub fn subresource_network_records(&self) -> &[SubresourceNetworkRecord] {
&self.subresource_network_records
}
pub fn websocket_network_events(&self) -> &[WebSocketNetworkEvent] {
&self.websocket_network_events
}
pub fn websocket_lifecycle_events(&self) -> &[WebSocketLifecycleEvent] {
&self.websocket_lifecycle_events
}
pub fn observable_output_items(&self) -> &[ScriptObservableOutputItem] {
&self.observable_output_items
}
pub fn extend_observable_output(&mut self, output: ScriptObservableOutput) {
for item in output.into_items() {
self.push_observable_output_item(item);
}
}
pub fn extend_network_output(&mut self, output: ScriptNetworkOutput) {
for item in output.into_items() {
self.push_network_output_item(item);
}
}
fn push_observable_output_item(&mut self, item: ScriptObservableOutputItem) {
self.observable_output_items.push(item.clone());
match item {
ScriptObservableOutputItem::ConsoleMessage(message) => {
self.console_messages.push(message);
}
ScriptObservableOutputItem::LifecycleError(error) => {
self.lifecycle_errors.push(error);
}
ScriptObservableOutputItem::InspectorIssue(issue) => {
self.inspector_issues.push(*issue);
}
}
}
fn push_network_output_item(&mut self, item: ScriptNetworkOutputItem) {
match item {
ScriptNetworkOutputItem::SubresourceNetworkRecord(record) => {
if let Some(handle) = record.request_handle() {
let handle = handle.get();
let state = self.staged_subresource_report_state_mut();
state.requests.remove(&handle);
state.responses.remove(&handle);
state.bodies.remove(&handle);
if !state.completed_handles.insert(handle) {
return;
}
}
self.subresource_network_records.push(*record);
}
ScriptNetworkOutputItem::SubresourceRequestStarted(request) => {
let handle = request.handle().get();
let state = self.staged_subresource_report_state_mut();
if state.completed_handles.contains(&handle) {
return;
}
state.requests.insert(handle, *request);
self.try_materialize_staged_subresource_record(handle);
}
ScriptNetworkOutputItem::SubresourceResponseStarted(response) => {
let handle = response.handle().get();
let state = self.staged_subresource_report_state_mut();
if state.completed_handles.contains(&handle) {
return;
}
state.responses.insert(handle, *response);
self.try_materialize_staged_subresource_record(handle);
}
ScriptNetworkOutputItem::SubresourceBodyFinished(body) => {
let handle = body.handle().get();
let state = self.staged_subresource_report_state_mut();
if state.completed_handles.contains(&handle) {
return;
}
state.bodies.insert(handle, *body);
self.try_materialize_staged_subresource_record(handle);
}
ScriptNetworkOutputItem::SubresourceDataReceived(_)
| ScriptNetworkOutputItem::SubresourceEventSourceMessageReceived(_) => {}
ScriptNetworkOutputItem::WebSocketNetworkEvent(event) => {
self.websocket_network_events.push(event);
}
ScriptNetworkOutputItem::WebSocketLifecycleEvent(event) => {
self.websocket_lifecycle_events.push(event);
}
}
}
fn staged_subresource_report_state_mut(&mut self) -> &mut StagedSubresourceReportState {
self.staged_subresource_lifecycle
.get_or_insert_with(|| Box::new(StagedSubresourceReportState::default()))
}
fn try_materialize_staged_subresource_record(&mut self, handle: u64) {
let record = {
let Some(state) = self.staged_subresource_lifecycle.as_ref() else {
return;
};
let Some(request) = state.requests.get(&handle) else {
return;
};
let Some(body) = state.bodies.get(&handle) else {
return;
};
let response = state.responses.get(&handle);
let Some(record) =
SubresourceNetworkRecord::from_staged_lifecycle(request, response, body)
else {
return;
};
record
};
let state = self
.staged_subresource_lifecycle
.as_mut()
.expect("staged subresource state should remain allocated");
state.requests.remove(&handle);
state.responses.remove(&handle);
state.bodies.remove(&handle);
if state.completed_handles.insert(handle) {
self.subresource_network_records.push(record);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BidiPreloadChannelHandoff {
pub handoff_id: String,
pub token: String,
pub channel: String,
pub ownership: Option<String>,
pub serialization_options: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentStartScript {
pub registry_key: Option<String>,
/// DevTools session whose Page agent owns a named-world script.
///
/// Blink keeps `InspectorPageAgent` state per DevTools session, so equal
/// `worldName` values from two sessions must materialize different worlds.
/// `None` is reserved for non-CDP registrations such as configured and
/// WebDriver BiDi preloads.
pub devtools_session: Option<DevToolsSessionKey>,
pub source: String,
pub world_name: Option<String>,
pub has_bidi_channel_argument: bool,
pub bidi_channel_handoffs: Vec<BidiPreloadChannelHandoff>,
}
impl DocumentStartScript {
pub fn with_registry_key(&self, registry_key: impl Into<String>) -> Self {
let mut script = self.clone();
script.registry_key = Some(registry_key.into());
script
}
pub fn with_devtools_session(&self, devtools_session: DevToolsSessionKey) -> Self {
let mut script = self.clone();
script.devtools_session = Some(devtools_session);
script
}
}
#[derive(Debug, Clone)]
pub struct SubresourceNetworkRecord {
inner: Arc<SubresourceNetworkRecordInner>,
}
#[derive(Debug, Clone)]
struct SubresourceNetworkRecordInner {
frame_id: Option<String>,
document_url: Url,
url: Url,
request_handle: Option<SubresourceNetworkRequestHandle>,
websocket_socket_id: Option<u64>,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
request_body_bytes: Option<Vec<u8>>,
resource_type: SubresourceResourceType,
request_initiator_type: SubresourceRequestInitiatorType,
request_cookie_report: Option<StoredCookieQueryReport>,
outcome: SubresourceNetworkOutcome,
cookie_set_reports: Vec<StoredCookieSetReport>,
from_cache: bool,
network_request_headers: Option<Vec<(String, String)>>,
negotiated_http_version: Option<NegotiatedHttpVersion>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SubresourceNetworkRequestHandle(u64);
impl SubresourceNetworkRequestHandle {
pub fn new(value: u64) -> Self {
Self(value)
}
pub fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceRequestStarted {
handle: SubresourceNetworkRequestHandle,
frame_id: Option<String>,
document_url: Url,
url: Url,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
request_body_bytes: Option<Vec<u8>>,
keepalive: bool,
resource_type: SubresourceResourceType,
request_initiator_type: SubresourceRequestInitiatorType,
request_cookie_report: Option<StoredCookieQueryReport>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceResponseStarted {
handle: SubresourceNetworkRequestHandle,
redirect_chain: Vec<NavigationRedirect>,
final_url: Url,
status: u16,
status_text: Option<String>,
response_headers: Vec<(String, Vec<u8>)>,
cookie_set_reports: Vec<StoredCookieSetReport>,
from_cache: bool,
network_request_headers: Option<Vec<(String, String)>>,
negotiated_http_version: Option<NegotiatedHttpVersion>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceDataReceived {
handle: SubresourceNetworkRequestHandle,
data_length: usize,
encoded_data_length: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceEventSourceMessageReceived {
handle: SubresourceNetworkRequestHandle,
event_name: String,
event_id: String,
data: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceBodyFinished {
handle: SubresourceNetworkRequestHandle,
result: SubresourceBodyFinishedResult,
data_was_streamed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubresourceBodyFinishedResult {
Ready(SubresourceResponseBody),
Failed(String),
FailedWithPartialBody {
error_text: String,
partial_body: SubresourceResponseBody,
},
}
impl PartialEq for SubresourceNetworkRecord {
fn eq(&self, other: &Self) -> bool {
let left = self.inner.as_ref();
let right = other.inner.as_ref();
Arc::ptr_eq(&self.inner, &other.inner)
|| (left.frame_id == right.frame_id
&& left.document_url == right.document_url
&& left.url == right.url
&& left.websocket_socket_id == right.websocket_socket_id
&& left.method == right.method
&& left.request_headers == right.request_headers
&& left.request_body == right.request_body
&& left.request_body_bytes == right.request_body_bytes
&& left.resource_type == right.resource_type
&& left.request_initiator_type == right.request_initiator_type
&& left.request_cookie_report == right.request_cookie_report
&& left.outcome == right.outcome
&& left.cookie_set_reports == right.cookie_set_reports
&& left.from_cache == right.from_cache
&& left.network_request_headers == right.network_request_headers
&& left.negotiated_http_version == right.negotiated_http_version)
}
}
impl Eq for SubresourceNetworkRecord {}
impl SubresourceRequestStarted {
pub fn new(
handle: SubresourceNetworkRequestHandle,
frame_id: Option<String>,
document_url: Url,
url: Url,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
resource_type: SubresourceResourceType,
request_initiator_type: SubresourceRequestInitiatorType,
request_cookie_report: Option<StoredCookieQueryReport>,
) -> Self {
let request_body_bytes = request_body_text_bytes(&request_body);
Self {
handle,
frame_id,
document_url,
url,
method,
request_headers,
request_body,
request_body_bytes,
keepalive: false,
resource_type,
request_initiator_type,
request_cookie_report,
}
}
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
self.handle
}
pub fn with_request_body_bytes(mut self, request_body_bytes: Option<Vec<u8>>) -> Self {
self.request_body_bytes =
request_body_bytes.or_else(|| request_body_text_bytes(&self.request_body));
self
}
pub fn with_keepalive(mut self, keepalive: bool) -> Self {
self.keepalive = keepalive;
self
}
pub fn frame_id(&self) -> Option<&str> {
self.frame_id.as_deref()
}
pub fn document_url(&self) -> &Url {
&self.document_url
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn method(&self) -> &str {
&self.method
}
pub fn request_headers(&self) -> &moli_fetch::RequestHeaders {
&self.request_headers
}
pub fn request_body(&self) -> Option<&str> {
self.request_body.as_deref()
}
pub fn request_body_bytes(&self) -> Option<&[u8]> {
self.request_body_bytes.as_deref()
}
pub fn keepalive(&self) -> bool {
self.keepalive
}
pub fn resource_type(&self) -> SubresourceResourceType {
self.resource_type
}
pub fn request_initiator_type(&self) -> SubresourceRequestInitiatorType {
self.request_initiator_type
}
pub fn request_cookie_report(&self) -> Option<&StoredCookieQueryReport> {
self.request_cookie_report.as_ref()
}
}
impl SubresourceResponseStarted {
pub fn new(
handle: SubresourceNetworkRequestHandle,
redirect_chain: Vec<NavigationRedirect>,
final_url: Url,
status: u16,
response_headers: Vec<(String, Vec<u8>)>,
cookie_set_reports: Vec<StoredCookieSetReport>,
) -> Self {
Self {
handle,
redirect_chain,
final_url,
status,
status_text: None,
response_headers,
cookie_set_reports,
from_cache: false,
network_request_headers: None,
negotiated_http_version: None,
}
}
pub fn with_status_text(mut self, status_text: Option<String>) -> Self {
self.status_text = status_text;
self
}
pub fn with_from_cache(mut self, from_cache: bool) -> Self {
self.from_cache = from_cache;
self
}
pub fn with_network_request_headers(
mut self,
network_request_headers: Option<Vec<(String, String)>>,
) -> Self {
self.network_request_headers = network_request_headers;
self
}
pub fn with_negotiated_http_version(
mut self,
negotiated_http_version: Option<NegotiatedHttpVersion>,
) -> Self {
self.negotiated_http_version = negotiated_http_version;
self
}
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
self.handle
}
pub fn redirect_chain(&self) -> &[NavigationRedirect] {
&self.redirect_chain
}
pub fn final_url(&self) -> &Url {
&self.final_url
}
pub fn status(&self) -> u16 {
self.status
}
pub fn status_text(&self) -> Option<&str> {
self.status_text.as_deref()
}
pub fn response_headers(&self) -> &[(String, Vec<u8>)] {
&self.response_headers
}
pub fn cookie_set_reports(&self) -> &[StoredCookieSetReport] {
&self.cookie_set_reports
}
pub fn from_cache(&self) -> bool {
self.from_cache
}
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
self.network_request_headers.as_deref()
}
pub fn negotiated_http_version(&self) -> Option<NegotiatedHttpVersion> {
self.negotiated_http_version
}
}
impl SubresourceDataReceived {
pub fn new(
handle: SubresourceNetworkRequestHandle,
data_length: usize,
encoded_data_length: usize,
) -> Self {
Self {
handle,
data_length,
encoded_data_length,
}
}
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
self.handle
}
pub fn data_length(&self) -> usize {
self.data_length
}
pub fn encoded_data_length(&self) -> usize {
self.encoded_data_length
}
}
impl SubresourceEventSourceMessageReceived {
pub fn new(
handle: SubresourceNetworkRequestHandle,
event_name: String,
event_id: String,
data: String,
) -> Self {
Self {
handle,
event_name,
event_id,
data,
}
}
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
self.handle
}
pub fn event_name(&self) -> &str {
&self.event_name
}
pub fn event_id(&self) -> &str {
&self.event_id
}
pub fn data(&self) -> &str {
&self.data
}
}
impl SubresourceBodyFinished {
pub fn ready(handle: SubresourceNetworkRequestHandle, body: SubresourceResponseBody) -> Self {
Self {
handle,
result: SubresourceBodyFinishedResult::Ready(body),
data_was_streamed: false,
}
}
pub fn ready_after_streaming(
handle: SubresourceNetworkRequestHandle,
body: SubresourceResponseBody,
) -> Self {
Self {
handle,
result: SubresourceBodyFinishedResult::Ready(body),
data_was_streamed: true,
}
}
pub fn failed(handle: SubresourceNetworkRequestHandle, error_text: String) -> Self {
Self {
handle,
result: SubresourceBodyFinishedResult::Failed(error_text),
data_was_streamed: false,
}
}
pub fn failed_with_partial_body(
handle: SubresourceNetworkRequestHandle,
error_text: String,
partial_body: SubresourceResponseBody,
) -> Self {
Self {
handle,
result: SubresourceBodyFinishedResult::FailedWithPartialBody {
error_text,
partial_body,
},
data_was_streamed: false,
}
}
pub fn handle(&self) -> SubresourceNetworkRequestHandle {
self.handle
}
pub fn result(&self) -> &SubresourceBodyFinishedResult {
&self.result
}
pub fn data_was_streamed(&self) -> bool {
self.data_was_streamed
}
}
#[derive(Debug, Clone)]
pub struct SubresourceResponseBody {
inner: Arc<SubresourceResponseBodyInner>,
}
#[derive(Debug)]
enum SubresourceResponseBodyInner {
Memory(Vec<u8>),
Disk {
storage: PooledSubresourceResponseBody,
},
ParkableImage(ParkableImage),
}
#[derive(Debug)]
struct PooledSubresourceResponseBody {
chunks: Vec<DiskData>,
trailing_bytes: Box<[u8]>,
len: usize,
}
impl PooledSubresourceResponseBody {
fn read_range(&self, offset: usize, max_len: usize) -> io::Result<Vec<u8>> {
if offset >= self.len || max_len == 0 {
return Ok(Vec::new());
}
let len = max_len.min(self.len - offset);
let mut bytes = vec![0; len];
self.read_exact_at(offset, &mut bytes)?;
Ok(bytes)
}
fn read_exact_at(&self, mut offset: usize, mut buffer: &mut [u8]) -> io::Result<()> {
for chunk in &self.chunks {
if buffer.is_empty() {
break;
}
if offset >= chunk.len() {
offset -= chunk.len();
continue;
}
let len = buffer.len().min(chunk.len() - offset);
chunk.read_exact_at(offset, &mut buffer[..len])?;
buffer = &mut buffer[len..];
offset = 0;
}
if !buffer.is_empty() {
let trailing = self.trailing_bytes.get(offset..).ok_or_else(|| {
io::Error::new(
io::ErrorKind::UnexpectedEof,
"pooled resource body ended before its recorded length",
)
})?;
let len = buffer.len().min(trailing.len());
buffer[..len].copy_from_slice(&trailing[..len]);
buffer = &mut buffer[len..];
}
if buffer.is_empty() {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"pooled resource body ended before its recorded length",
))
}
}
fn write_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
for chunk in &self.chunks {
chunk.write_to(writer)?;
}
writer.write_all(&self.trailing_bytes)
}
}
impl PartialEq for SubresourceResponseBody {
fn eq(&self, other: &Self) -> bool {
if Arc::ptr_eq(&self.inner, &other.inner) {
return true;
}
match (self.inner.as_ref(), other.inner.as_ref()) {
(
SubresourceResponseBodyInner::Memory(left),
SubresourceResponseBodyInner::Memory(right),
) => left == right,
(
SubresourceResponseBodyInner::ParkableImage(left),
SubresourceResponseBodyInner::ParkableImage(right),
) => left.shares_storage_with(right),
_ => false,
}
}
}
impl Eq for SubresourceResponseBody {}
#[derive(Debug)]
pub struct SubresourceResponseBodyWriter {
memory_limit: usize,
len: usize,
memory: Vec<u8>,
disk_pool: Option<DiskPool>,
disk_chunks: Vec<DiskData>,
disk_write_failed: bool,
}
impl Default for SubresourceResponseBodyWriter {
fn default() -> Self {
Self::new(SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT)
}
}
impl SubresourceResponseBodyWriter {
pub fn new(memory_limit: usize) -> Self {
Self::with_memory_limit_and_disk_pool(memory_limit, None)
}
/// Creates a writer using the default in-memory threshold and a shared
/// browser resource disk pool when one is available.
pub fn with_disk_pool(disk_pool: Option<DiskPool>) -> Self {
Self::with_memory_limit_and_disk_pool(SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT, disk_pool)
}
pub fn with_memory_limit_and_disk_pool(
memory_limit: usize,
disk_pool: Option<DiskPool>,
) -> Self {
Self {
memory_limit,
len: 0,
memory: Vec::new(),
disk_pool,
disk_chunks: Vec::new(),
disk_write_failed: false,
}
}
pub fn append(&mut self, mut bytes: &[u8]) {
if bytes.is_empty() {
return;
}
self.len = self.len.saturating_add(bytes.len());
if self.disk_write_failed || self.disk_pool.is_none() {
self.memory.extend_from_slice(bytes);
return;
}
if self.disk_chunks.is_empty()
&& self.memory.len().saturating_add(bytes.len()) <= self.memory_limit
{
self.memory.extend_from_slice(bytes);
return;
}
let chunk_size = self.memory_limit.max(1);
while !bytes.is_empty() {
if self.memory.len() == chunk_size && !self.flush_memory_to_disk() {
self.disk_write_failed = true;
self.memory.extend_from_slice(bytes);
return;
}
let available = chunk_size - self.memory.len();
let consumed = available.min(bytes.len());
self.memory.extend_from_slice(&bytes[..consumed]);
bytes = &bytes[consumed..];
if self.memory.len() == chunk_size && !self.flush_memory_to_disk() {
self.disk_write_failed = true;
self.memory.extend_from_slice(bytes);
return;
}
}
}
pub fn finish(mut self) -> SubresourceResponseBody {
if !self.disk_chunks.is_empty() {
if !self.memory.is_empty() && !self.disk_write_failed {
let _ = self.flush_memory_to_disk();
}
let stored_len = self
.disk_chunks
.iter()
.map(DiskData::len)
.sum::<usize>()
.saturating_add(self.memory.len());
debug_assert_eq!(stored_len, self.len);
return SubresourceResponseBody {
inner: Arc::new(SubresourceResponseBodyInner::Disk {
storage: PooledSubresourceResponseBody {
chunks: std::mem::take(&mut self.disk_chunks),
trailing_bytes: std::mem::take(&mut self.memory).into_boxed_slice(),
len: self.len,
},
}),
};
}
SubresourceResponseBody::from_bytes(std::mem::take(&mut self.memory))
}
fn flush_memory_to_disk(&mut self) -> bool {
if self.memory.is_empty() {
return true;
}
let Some(pool) = self.disk_pool.as_ref() else {
return false;
};
let Ok(Some(chunk)) = pool.store(&self.memory) else {
return false;
};
self.disk_chunks.push(chunk);
self.memory.clear();
true
}
}
impl SubresourceResponseBody {
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self {
inner: Arc::new(SubresourceResponseBodyInner::Memory(bytes)),
}
}
/// Builds a response body that shares the encoded image backing.
/// Cloning this body does not clone bytes.
pub fn from_parkable_image(image: ParkableImage) -> Self {
Self {
inner: Arc::new(SubresourceResponseBodyInner::ParkableImage(image)),
}
}
/// Copies the exact bytes from a materialized fetch response into the
/// renderer-neutral subresource body carrier.
pub fn from_fetch_response(response: &Response) -> Self {
Self::from_bytes(response.body_bytes().to_vec())
}
/// Copies the exact bytes from a materialized navigation response into the
/// renderer-neutral subresource body carrier.
pub fn from_navigation_response(response: &NavigationResponse) -> Self {
Self::from_bytes(response.body_bytes().to_vec())
}
pub fn bytes(&self) -> Cow<'_, [u8]> {
self.diagnostic_bytes()
}
/// Best-effort byte view for diagnostics and legacy tests. Production
/// protocol paths should use `try_bytes`.
pub fn diagnostic_bytes(&self) -> Cow<'_, [u8]> {
self.try_bytes().unwrap_or_else(|_| Cow::Owned(Vec::new()))
}
pub fn try_bytes(&self) -> io::Result<Cow<'_, [u8]>> {
match self.inner.as_ref() {
SubresourceResponseBodyInner::Memory(bytes) => Ok(Cow::Borrowed(bytes)),
SubresourceResponseBodyInner::Disk { .. } => self.materialize_bytes().map(Cow::Owned),
SubresourceResponseBodyInner::ParkableImage(image) => image
.snapshot()
.map(|snapshot| Cow::Owned(snapshot.to_vec())),
}
}
/// Explicit byte-level equality for callers that intentionally accept
/// source I/O while comparing bodies.
pub fn try_byte_eq(&self, other: &Self) -> io::Result<bool> {
if self.len() != other.len() {
return Ok(false);
}
let left = self.try_bytes()?;
let right = other.try_bytes()?;
Ok(left == right)
}
/// Best-effort byte-level equality for diagnostics and legacy tests.
pub fn diagnostic_byte_eq(&self, other: &Self) -> bool {
self.try_byte_eq(other).unwrap_or(false)
}
pub fn clone_body_bytes(&self) -> Vec<u8> {
self.diagnostic_clone_body_bytes()
}
/// Best-effort owned byte clone for diagnostics and legacy tests.
/// Production protocol paths should use `materialize_bytes`.
pub fn diagnostic_clone_body_bytes(&self) -> Vec<u8> {
self.materialize_bytes().unwrap_or_default()
}
pub fn clone_body_bytes_from(&self, offset: usize) -> Vec<u8> {
self.diagnostic_clone_body_bytes_from(offset)
}
/// Best-effort owned byte clone from an offset for diagnostics and legacy
/// tests. Production protocol paths should use `materialize_bytes_from`.
pub fn diagnostic_clone_body_bytes_from(&self, offset: usize) -> Vec<u8> {
self.materialize_bytes_from(offset).unwrap_or_default()
}
pub fn materialize_bytes(&self) -> io::Result<Vec<u8>> {
self.materialize_bytes_from(0)
}
pub fn materialize_bytes_from(&self, offset: usize) -> io::Result<Vec<u8>> {
match self.inner.as_ref() {
SubresourceResponseBodyInner::Memory(bytes) => {
Ok(bytes.get(offset..).map(<[u8]>::to_vec).unwrap_or_default())
}
SubresourceResponseBodyInner::Disk { storage, .. } => {
storage.read_range(offset, storage.len.saturating_sub(offset))
}
SubresourceResponseBodyInner::ParkableImage(image) => {
image.read_range(offset, image.len().saturating_sub(offset))
}
}
}
pub fn read_chunk(&self, offset: usize, max_len: usize) -> io::Result<Vec<u8>> {
if max_len == 0 {
return Ok(Vec::new());
}
match self.inner.as_ref() {
SubresourceResponseBodyInner::Memory(bytes) => {
let Some(remaining) = bytes.get(offset..) else {
return Ok(Vec::new());
};
let len = remaining.len().min(max_len);
Ok(remaining[..len].to_vec())
}
SubresourceResponseBodyInner::Disk { storage, .. } => {
storage.read_range(offset, max_len)
}
SubresourceResponseBodyInner::ParkableImage(image) => image.read_range(offset, max_len),
}
}
pub fn write_bytes_to<W: Write>(&self, writer: &mut W) -> io::Result<()> {
match self.inner.as_ref() {
SubresourceResponseBodyInner::Memory(bytes) => writer.write_all(bytes),
SubresourceResponseBodyInner::Disk { storage, .. } => storage.write_to(writer),
SubresourceResponseBodyInner::ParkableImage(image) => {
writer.write_all(&image.snapshot()?)
}
}
}
pub fn len(&self) -> usize {
match self.inner.as_ref() {
SubresourceResponseBodyInner::Memory(bytes) => bytes.len(),
SubresourceResponseBodyInner::Disk { storage, .. } => storage.len,
SubresourceResponseBodyInner::ParkableImage(image) => image.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, Default)]
pub struct SubresourceResponseWaitCriteria {
pub url_contains: Option<String>,
pub url_regex: Option<Regex>,
pub body_contains: Option<String>,
pub body_regex: Option<Regex>,
pub json_path_equals: Option<SubresourceJsonPathEquals>,
pub json_path_regex: Option<SubresourceJsonPathRegex>,
}
impl SubresourceResponseWaitCriteria {
pub fn is_empty(&self) -> bool {
self.url_contains.is_none() && self.url_regex.is_none() && !self.requires_response_body()
}
/// Compatibility matcher for diagnostic surfaces that prefer best-effort
/// matching over surfacing a response-body source read error.
pub fn matches(&self, record: &SubresourceNetworkRecord) -> bool {
self.diagnostic_matches(record)
}
/// Best-effort matcher for diagnostics such as trace summaries. Unreadable
/// response bodies are treated as no match so diagnostics can still render.
pub fn diagnostic_matches(&self, record: &SubresourceNetworkRecord) -> bool {
self.try_matches(record).unwrap_or(false)
}
/// Fallible matcher for production wait paths. A body-backed criterion
/// should not silently treat an unreadable response body as empty text.
pub fn try_matches(&self, record: &SubresourceNetworkRecord) -> io::Result<bool> {
if let Some(needle) = self.url_contains.as_deref()
&& !record.url().as_str().contains(needle)
&& !matches!(
record.outcome(),
SubresourceNetworkOutcome::Success { final_url, .. }
if final_url.as_str().contains(needle)
)
{
return Ok(false);
}
if let Some(regex) = self.url_regex.as_ref()
&& !regex.is_match(record.url().as_str())
&& !matches!(
record.outcome(),
SubresourceNetworkOutcome::Success { final_url, .. }
if regex.is_match(final_url.as_str())
)
{
return Ok(false);
}
let SubresourceNetworkOutcome::Success {
response_headers,
response_body,
..
} = record.outcome()
else {
return Ok(!self.requires_response_body());
};
let response_body_bytes = if self.requires_response_body() {
Some(response_body.try_bytes()?)
} else {
None
};
let response_body_text = response_body_bytes.as_deref().map(String::from_utf8_lossy);
if let Some(needle) = self.body_contains.as_deref()
&& !response_body_text
.as_deref()
.unwrap_or_default()
.contains(needle)
{
return Ok(false);
}
if let Some(regex) = self.body_regex.as_ref()
&& !regex.is_match(response_body_text.as_deref().unwrap_or_default())
{
return Ok(false);
}
if let Some(expectation) = self.json_path_equals.as_ref()
&& !json_path_equals(
response_headers,
response_body_text.as_deref().unwrap_or_default(),
expectation,
)
{
return Ok(false);
}
if let Some(expectation) = self.json_path_regex.as_ref()
&& !json_path_matches_regex(
response_headers,
response_body_text.as_deref().unwrap_or_default(),
expectation,
)
{
return Ok(false);
}
Ok(true)
}
fn requires_response_body(&self) -> bool {
self.body_contains.is_some()
|| self.body_regex.is_some()
|| self.json_path_equals.is_some()
|| self.json_path_regex.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceJsonPathEquals {
pub path: Vec<String>,
pub expected: String,
}
#[derive(Debug, Clone)]
pub struct SubresourceJsonPathRegex {
pub path: Vec<String>,
pub regex: Regex,
}
fn json_path_equals(
headers: &[(String, Vec<u8>)],
body: &str,
expectation: &SubresourceJsonPathEquals,
) -> bool {
json_path_satisfies(headers, body, &expectation.path, |value| {
json_value_equals_string(value, &expectation.expected)
})
}
fn json_path_matches_regex(
headers: &[(String, Vec<u8>)],
body: &str,
expectation: &SubresourceJsonPathRegex,
) -> bool {
json_path_satisfies(headers, body, &expectation.path, |value| {
json_value_matches_regex(value, &expectation.regex)
})
}
fn json_path_satisfies(
headers: &[(String, Vec<u8>)],
body: &str,
path: &[String],
predicate: impl FnOnce(&Value) -> bool,
) -> bool {
let Some(content_type) = header_value(headers, "content-type") else {
return false;
};
if !is_json_module_mime(&content_type) {
return false;
}
let Ok(parsed) = serde_json::from_str::<Value>(body) else {
return false;
};
let Some(value) = json_path_value(&parsed, path) else {
return false;
};
predicate(value)
}
fn json_path_value<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
for segment in path {
value = match value {
Value::Object(object) => object.get(segment)?,
Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(value)
}
fn json_value_equals_string(value: &Value, expected: &str) -> bool {
match value {
Value::String(actual) => actual == expected,
Value::Bool(actual) => actual.to_string() == expected,
Value::Number(actual) => actual.to_string() == expected,
Value::Null => expected == "null",
Value::Array(_) | Value::Object(_) => *value == expected,
}
}
fn json_value_matches_regex(value: &Value, regex: &Regex) -> bool {
match value {
Value::String(actual) => regex.is_match(actual),
Value::Bool(actual) => regex.is_match(if *actual { "true" } else { "false" }),
Value::Number(actual) => regex.is_match(&actual.to_string()),
Value::Null => regex.is_match("null"),
Value::Array(_) | Value::Object(_) => false,
}
}
fn header_value<'a>(
headers: &'a [(String, Vec<u8>)],
name: &str,
) -> Option<std::borrow::Cow<'a, str>> {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(_, value)| moli_fetch::decode_header_value(value))
}
impl SubresourceNetworkRecord {
fn from_staged_lifecycle(
request: &SubresourceRequestStarted,
response: Option<&SubresourceResponseStarted>,
body: &SubresourceBodyFinished,
) -> Option<Self> {
if request.handle() != body.handle()
|| response.is_some_and(|response| response.handle() != request.handle())
{
return None;
}
match body.result() {
SubresourceBodyFinishedResult::Ready(response_body) => {
let response = response?;
let mut record = Self::success_with_body(
request.frame_id.clone(),
request.document_url.clone(),
request.url.clone(),
request.method.clone(),
request.request_headers.clone(),
request.request_body.clone(),
request.resource_type,
request.request_cookie_report.clone(),
response.redirect_chain.clone(),
response.final_url.clone(),
response.status,
response.response_headers.clone(),
response_body.clone(),
response.cookie_set_reports.clone(),
)
.with_request_handle(request.handle())
.with_request_body_bytes(request.request_body_bytes.clone())
.with_request_initiator_type(request.request_initiator_type)
.with_from_cache(response.from_cache)
.with_network_request_headers(response.network_request_headers.clone())
.with_negotiated_http_version(response.negotiated_http_version);
if let Some(status_text) = response.status_text.clone() {
record = record.with_response_status_text(status_text);
}
Some(record)
}
SubresourceBodyFinishedResult::Failed(error_text)
| SubresourceBodyFinishedResult::FailedWithPartialBody { error_text, .. } => {
Some(Self {
inner: Arc::new(SubresourceNetworkRecordInner {
frame_id: request.frame_id.clone(),
document_url: request.document_url.clone(),
url: request.url.clone(),
request_handle: Some(request.handle()),
websocket_socket_id: None,
method: request.method.clone(),
request_headers: request.request_headers.clone(),
request_body: request.request_body.clone(),
request_body_bytes: request.request_body_bytes.clone(),
resource_type: request.resource_type,
request_initiator_type: request.request_initiator_type,
request_cookie_report: request.request_cookie_report.clone(),
outcome: SubresourceNetworkOutcome::Failure {
error_text: error_text.clone(),
},
cookie_set_reports: response
.map(|response| response.cookie_set_reports.clone())
.unwrap_or_default(),
from_cache: response.is_some_and(|response| response.from_cache),
network_request_headers: response
.and_then(|response| response.network_request_headers.clone()),
negotiated_http_version: response
.and_then(|response| response.negotiated_http_version),
}),
})
}
}
}
pub fn success(
frame_id: Option<String>,
document_url: Url,
url: Url,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
resource_type: SubresourceResourceType,
request_cookie_report: Option<StoredCookieQueryReport>,
redirect_chain: Vec<NavigationRedirect>,
final_url: Url,
status: u16,
response_headers: Vec<(String, Vec<u8>)>,
response_body: String,
cookie_set_reports: Vec<StoredCookieSetReport>,
) -> Self {
Self::success_with_body(
frame_id,
document_url,
url,
method,
request_headers,
request_body,
resource_type,
request_cookie_report,
redirect_chain,
final_url,
status,
response_headers,
SubresourceResponseBody::from_bytes(response_body.into_bytes()),
cookie_set_reports,
)
}
pub fn success_with_body(
frame_id: Option<String>,
document_url: Url,
url: Url,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
resource_type: SubresourceResourceType,
request_cookie_report: Option<StoredCookieQueryReport>,
redirect_chain: Vec<NavigationRedirect>,
final_url: Url,
status: u16,
response_headers: Vec<(String, Vec<u8>)>,
response_body: SubresourceResponseBody,
cookie_set_reports: Vec<StoredCookieSetReport>,
) -> Self {
let request_body_bytes = request_body_text_bytes(&request_body);
Self {
inner: Arc::new(SubresourceNetworkRecordInner {
frame_id,
document_url,
url,
request_handle: None,
websocket_socket_id: None,
method,
request_headers,
request_body,
request_body_bytes,
resource_type,
request_initiator_type: SubresourceRequestInitiatorType::Script,
request_cookie_report,
outcome: SubresourceNetworkOutcome::Success {
redirect_chain,
final_url,
status,
status_text: None,
response_headers,
response_body,
},
cookie_set_reports,
from_cache: false,
network_request_headers: None,
negotiated_http_version: None,
}),
}
}
pub fn failure(
frame_id: Option<String>,
document_url: Url,
url: Url,
method: String,
request_headers: moli_fetch::RequestHeaders,
request_body: Option<String>,
resource_type: SubresourceResourceType,
error_text: String,
) -> Self {
let request_body_bytes = request_body_text_bytes(&request_body);
Self {
inner: Arc::new(SubresourceNetworkRecordInner {
frame_id,
document_url,
url,
request_handle: None,
websocket_socket_id: None,
method,
request_headers,
request_body,
request_body_bytes,
resource_type,
request_initiator_type: SubresourceRequestInitiatorType::Script,
request_cookie_report: None,
outcome: SubresourceNetworkOutcome::Failure { error_text },
cookie_set_reports: Vec::new(),
from_cache: false,
network_request_headers: None,
negotiated_http_version: None,
}),
}
}
pub fn with_websocket_socket_id(mut self, socket_id: u64) -> Self {
Arc::make_mut(&mut self.inner).websocket_socket_id = Some(socket_id);
self
}
pub fn with_request_handle(mut self, handle: SubresourceNetworkRequestHandle) -> Self {
Arc::make_mut(&mut self.inner).request_handle = Some(handle);
self
}
pub fn with_request_body_bytes(mut self, request_body_bytes: Option<Vec<u8>>) -> Self {
let inner = Arc::make_mut(&mut self.inner);
inner.request_body_bytes =
request_body_bytes.or_else(|| request_body_text_bytes(&inner.request_body));
self
}
pub fn with_request_initiator_type(
mut self,
request_initiator_type: SubresourceRequestInitiatorType,
) -> Self {
Arc::make_mut(&mut self.inner).request_initiator_type = request_initiator_type;
self
}
pub fn with_response_status_text(mut self, status_text: impl Into<String>) -> Self {
if let SubresourceNetworkOutcome::Success {
status_text: current,
..
} = &mut Arc::make_mut(&mut self.inner).outcome
{
*current = Some(status_text.into());
}
self
}
pub fn with_from_cache(mut self, from_cache: bool) -> Self {
Arc::make_mut(&mut self.inner).from_cache = from_cache;
self
}
pub fn with_network_request_headers(
mut self,
network_request_headers: Option<Vec<(String, String)>>,
) -> Self {
Arc::make_mut(&mut self.inner).network_request_headers = network_request_headers;
self
}
pub fn with_negotiated_http_version(
mut self,
negotiated_http_version: Option<NegotiatedHttpVersion>,
) -> Self {
Arc::make_mut(&mut self.inner).negotiated_http_version = negotiated_http_version;
self
}
pub fn request_handle(&self) -> Option<SubresourceNetworkRequestHandle> {
self.inner.request_handle
}
pub fn websocket_socket_id(&self) -> Option<u64> {
self.inner.websocket_socket_id
}
pub fn url(&self) -> &Url {
&self.inner.url
}
pub fn frame_id(&self) -> Option<&str> {
self.inner.frame_id.as_deref()
}
pub fn document_url(&self) -> &Url {
&self.inner.document_url
}
pub fn method(&self) -> &str {
&self.inner.method
}
pub fn request_headers(&self) -> &moli_fetch::RequestHeaders {
&self.inner.request_headers
}
pub fn request_body(&self) -> Option<&str> {
self.inner.request_body.as_deref()
}
pub fn request_body_bytes(&self) -> Option<&[u8]> {
self.inner.request_body_bytes.as_deref()
}
pub fn resource_type(&self) -> SubresourceResourceType {
self.inner.resource_type
}
pub fn request_initiator_type(&self) -> SubresourceRequestInitiatorType {
self.inner.request_initiator_type
}
pub fn request_cookie_report(&self) -> Option<&StoredCookieQueryReport> {
self.inner.request_cookie_report.as_ref()
}
pub fn outcome(&self) -> &SubresourceNetworkOutcome {
&self.inner.outcome
}
pub fn try_response_body_byte_eq(&self, other: &Self) -> io::Result<bool> {
match (&self.inner.outcome, &other.inner.outcome) {
(
SubresourceNetworkOutcome::Success {
response_body: left,
..
},
SubresourceNetworkOutcome::Success {
response_body: right,
..
},
) => left.try_byte_eq(right),
_ => Ok(false),
}
}
pub fn diagnostic_response_body_byte_eq(&self, other: &Self) -> bool {
self.try_response_body_byte_eq(other).unwrap_or(false)
}
pub fn cookie_set_reports(&self) -> &[StoredCookieSetReport] {
&self.inner.cookie_set_reports
}
pub fn from_cache(&self) -> bool {
self.inner.from_cache
}
pub fn network_request_headers(&self) -> Option<&[(String, String)]> {
self.inner.network_request_headers.as_deref()
}
pub fn negotiated_http_version(&self) -> Option<NegotiatedHttpVersion> {
self.inner.negotiated_http_version
}
}
fn request_body_text_bytes(request_body: &Option<String>) -> Option<Vec<u8>> {
request_body.as_ref().map(|body| body.as_bytes().to_vec())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSubresourceFetchInfo {
pub internal_id: u64,
pub network_request_handle: Option<SubresourceNetworkRequestHandle>,
pub frame_id: Option<String>,
pub document_url: Url,
pub url: Url,
pub websocket_socket_id: Option<u64>,
pub method: String,
pub request_headers: moli_fetch::RequestHeaders,
pub request_body: Option<String>,
pub request_body_bytes: Option<Vec<u8>>,
pub resource_type: SubresourceResourceType,
pub request_cookie_report: Option<StoredCookieQueryReport>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSubresourceResponseInfo {
pub internal_id: u64,
pub url: Url,
pub final_url: Url,
pub method: String,
pub request_headers: moli_fetch::RequestHeaders,
pub request_body: Option<String>,
pub resource_type: SubresourceResourceType,
pub request_cookie_report: Option<StoredCookieQueryReport>,
pub network_request_headers: Option<Vec<(String, String)>>,
pub response_status: u16,
pub response_headers: Vec<(String, Vec<u8>)>,
/// Exact response bytes plus the lossy compatibility text view needed while
/// a response-stage Fetch pause is held.
pub response_body: SubresourceResponseBody,
pub from_cache: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSubresourceAuthInfo {
pub internal_id: u64,
pub url: Url,
pub method: String,
pub request_headers: moli_fetch::RequestHeaders,
pub request_body: Option<String>,
pub resource_type: SubresourceResourceType,
pub request_cookie_report: Option<StoredCookieQueryReport>,
pub network_request_headers: Option<Vec<(String, String)>>,
pub challenge: SubresourceAuthChallenge,
pub intercept_response: bool,
pub response_final_url: Url,
pub response_status: u16,
pub response_headers: Vec<(String, Vec<u8>)>,
pub response_body: SubresourceResponseBody,
pub response_from_cache: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceAuthChallenge {
pub source: String,
pub scheme: String,
pub realm: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubresourceAuthTarget {
Server,
Proxy,
ProxyHeader,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubresourceAuthScheme {
Basic,
Digest,
Negotiate,
Ntlm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubresourceAuthCredentials {
pub target: SubresourceAuthTarget,
pub scheme: SubresourceAuthScheme,
pub username: String,
pub password: String,
}
pub fn extract_subresource_auth_challenge(
headers: &[(String, Vec<u8>)],
) -> Option<SubresourceAuthChallenge> {
let mut first_challenge = None;
for (source, value) in headers.iter().filter_map(|(name, value)| {
if name.eq_ignore_ascii_case("www-authenticate") {
Some(("Server", moli_fetch::decode_header_value(value)))
} else if name.eq_ignore_ascii_case("proxy-authenticate") {
Some(("Proxy", moli_fetch::decode_header_value(value)))
} else {
None
}
}) {
for candidate in parse_auth_challenge_candidates(&value) {
let challenge = SubresourceAuthChallenge {
source: source.to_owned(),
scheme: candidate.scheme,
realm: candidate.realm,
};
if first_challenge.is_none() {
first_challenge = Some(challenge.clone());
}
if subresource_auth_scheme_from_name(&challenge.scheme).is_some() {
return Some(challenge);
}
}
}
first_challenge
}
pub fn subresource_auth_credentials_for_challenge(
challenge: &SubresourceAuthChallenge,
username: &str,
password: &str,
) -> Option<SubresourceAuthCredentials> {
let target = if challenge.source.eq_ignore_ascii_case("proxy") {
if challenge.scheme.is_empty() || challenge.scheme.eq_ignore_ascii_case("basic") {
SubresourceAuthTarget::ProxyHeader
} else {
SubresourceAuthTarget::Proxy
}
} else {
SubresourceAuthTarget::Server
};
let scheme = subresource_auth_scheme_from_name(&challenge.scheme)?;
Some(SubresourceAuthCredentials {
target,
scheme,
username: username.to_owned(),
password: password.to_owned(),
})
}
fn subresource_auth_scheme_from_name(name: &str) -> Option<SubresourceAuthScheme> {
match name.to_ascii_lowercase().as_str() {
"" | "basic" => Some(SubresourceAuthScheme::Basic),
"digest" => Some(SubresourceAuthScheme::Digest),
"negotiate" => Some(SubresourceAuthScheme::Negotiate),
"ntlm" => Some(SubresourceAuthScheme::Ntlm),
_ => None,
}
}
#[derive(Debug, Clone)]
struct ParsedAuthChallenge {
scheme: String,
realm: String,
}
fn parse_auth_challenge_candidates(value: &str) -> Vec<ParsedAuthChallenge> {
ChallengeParser::new(value)
.filter_map(Result::ok)
.map(|challenge| ParsedAuthChallenge {
scheme: challenge.scheme.to_ascii_lowercase(),
realm: challenge
.params
.iter()
.find_map(|(name, value)| {
name.eq_ignore_ascii_case("realm")
.then(|| value.to_unescaped())
})
.unwrap_or_default(),
})
.collect()
}
impl From<SubresourceAuthCredentials> for RequestAuth {
fn from(value: SubresourceAuthCredentials) -> Self {
Self {
target: match value.target {
SubresourceAuthTarget::Server => RequestAuthTarget::Server,
SubresourceAuthTarget::Proxy => RequestAuthTarget::Proxy,
SubresourceAuthTarget::ProxyHeader => RequestAuthTarget::ProxyHeader,
},
scheme: match value.scheme {
SubresourceAuthScheme::Basic => RequestAuthScheme::Basic,
SubresourceAuthScheme::Digest => RequestAuthScheme::Digest,
SubresourceAuthScheme::Negotiate => RequestAuthScheme::Negotiate,
SubresourceAuthScheme::Ntlm => RequestAuthScheme::Ntlm,
},
username: value.username,
password: value.password,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingSubresourceContinueOutcome {
Started,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingSubresourceContinueEvent {
Completed { internal_id: u64 },
ResponsePaused(PendingSubresourceResponseInfo),
AuthRequired(PendingSubresourceAuthInfo),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubresourceResourceType {
Script,
Stylesheet,
Image,
Font,
Audio,
Video,
Media,
TextTrack,
Fetch,
EventSource,
Xhr,
Ping,
CspReport,
Dictionary,
Manifest,
WebSocket,
}
bitflags::bitflags! {
/// Browser-level opt-ins for resource families that the lightweight
/// default does not fetch from the network.
///
/// This mask deliberately contains only optional render/media resources.
/// Script, stylesheet, Fetch/XHR, and other behaviorally required requests
/// are outside this policy and remain enabled.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct OptionalResourceFetchMask: u8 {
const NONE = 0;
const IMAGE = 1 << 0;
const FONT = 1 << 1;
const AUDIO = 1 << 2;
const VIDEO = 1 << 3;
const MEDIA = 1 << 4;
const TEXT_TRACK = 1 << 5;
const ALL = Self::IMAGE.bits()
| Self::FONT.bits()
| Self::AUDIO.bits()
| Self::VIDEO.bits()
| Self::MEDIA.bits()
| Self::TEXT_TRACK.bits();
}
}
impl OptionalResourceFetchMask {
pub const fn for_resource_type(resource_type: SubresourceResourceType) -> Option<Self> {
match resource_type {
SubresourceResourceType::Image => Some(Self::IMAGE),
SubresourceResourceType::Font => Some(Self::FONT),
SubresourceResourceType::Audio => Some(Self::AUDIO),
SubresourceResourceType::Video => Some(Self::VIDEO),
SubresourceResourceType::Media => Some(Self::MEDIA),
SubresourceResourceType::TextTrack => Some(Self::TEXT_TRACK),
SubresourceResourceType::Script
| SubresourceResourceType::Stylesheet
| SubresourceResourceType::Fetch
| SubresourceResourceType::EventSource
| SubresourceResourceType::Xhr
| SubresourceResourceType::Ping
| SubresourceResourceType::CspReport
| SubresourceResourceType::Dictionary
| SubresourceResourceType::Manifest
| SubresourceResourceType::WebSocket => None,
}
}
pub const fn allows(self, resource_type: SubresourceResourceType) -> bool {
match Self::for_resource_type(resource_type) {
Some(resource) => self.contains(resource),
None => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubresourceRequestInitiatorType {
Other,
Parser,
Script,
Css,
}
impl SubresourceRequestInitiatorType {
pub fn as_cdp_initiator_type(self) -> &'static str {
match self {
Self::Parser | Self::Css => "parser",
Self::Script => "script",
Self::Other => "other",
}
}
pub fn as_bidi_request_initiator_type(self) -> Option<&'static str> {
match self {
Self::Script => Some("script"),
Self::Css => Some("css"),
Self::Other | Self::Parser => None,
}
}
}
impl SubresourceResourceType {
pub fn as_cdp_type(self) -> &'static str {
match self {
Self::Script => "Script",
Self::Stylesheet => "Stylesheet",
Self::Image => "Image",
Self::Font => "Font",
Self::Audio | Self::Video => "Media",
Self::Media => "Media",
Self::TextTrack => "TextTrack",
Self::Fetch => "Fetch",
Self::EventSource => "EventSource",
Self::Xhr => "XHR",
Self::Ping => "Ping",
Self::CspReport => "CSPViolationReport",
Self::Dictionary => "Other",
Self::Manifest => "Manifest",
Self::WebSocket => "WebSocket",
}
}
/// Returns the resource type exposed by the CDP Fetch interception domain.
///
/// Chromium's loader interception receives Fetch, EventSource, and XHR as
/// Blink's shared `kXhr` resource type. The Network domain still reports
/// their higher-level types through `as_cdp_type`.
pub fn as_cdp_fetch_interception_type(self) -> &'static str {
match self {
Self::Fetch | Self::EventSource | Self::Xhr => "XHR",
_ => self.as_cdp_type(),
}
}
pub fn has_same_cdp_fetch_interception_type(self, other: Self) -> bool {
self.as_cdp_fetch_interception_type() == other.as_cdp_fetch_interception_type()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubresourceNetworkOutcome {
Success {
redirect_chain: Vec<NavigationRedirect>,
final_url: Url,
status: u16,
status_text: Option<String>,
response_headers: Vec<(String, Vec<u8>)>,
response_body: SubresourceResponseBody,
},
Failure {
error_text: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebSocketNetworkEvent {
socket_id: u64,
document_url: Url,
url: Url,
direction: WebSocketFrameDirection,
opcode: WebSocketFrameOpcode,
payload_length: usize,
}
impl WebSocketNetworkEvent {
pub fn new(
socket_id: u64,
document_url: Url,
url: Url,
direction: WebSocketFrameDirection,
opcode: WebSocketFrameOpcode,
payload_length: usize,
) -> Self {
Self {
socket_id,
document_url,
url,
direction,
opcode,
payload_length,
}
}
pub fn socket_id(&self) -> u64 {
self.socket_id
}
pub fn document_url(&self) -> &Url {
&self.document_url
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn direction(&self) -> WebSocketFrameDirection {
self.direction
}
pub fn opcode(&self) -> WebSocketFrameOpcode {
self.opcode
}
pub fn payload_length(&self) -> usize {
self.payload_length
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebSocketLifecycleEvent {
socket_id: u64,
document_url: Url,
url: Url,
kind: WebSocketLifecycleKind,
error_text: Option<String>,
close_code: Option<u16>,
close_reason: Option<String>,
was_clean: Option<bool>,
}
impl WebSocketLifecycleEvent {
pub fn open(socket_id: u64, document_url: Url, url: Url) -> Self {
Self {
socket_id,
document_url,
url,
kind: WebSocketLifecycleKind::Open,
error_text: None,
close_code: None,
close_reason: None,
was_clean: None,
}
}
pub fn error(socket_id: u64, document_url: Url, url: Url, error_text: String) -> Self {
Self {
socket_id,
document_url,
url,
kind: WebSocketLifecycleKind::Error,
error_text: Some(error_text),
close_code: None,
close_reason: None,
was_clean: None,
}
}
pub fn closing(socket_id: u64, document_url: Url, url: Url) -> Self {
Self {
socket_id,
document_url,
url,
kind: WebSocketLifecycleKind::Closing,
error_text: None,
close_code: None,
close_reason: None,
was_clean: None,
}
}
pub fn close(
socket_id: u64,
document_url: Url,
url: Url,
code: u16,
reason: String,
was_clean: bool,
) -> Self {
Self {
socket_id,
document_url,
url,
kind: WebSocketLifecycleKind::Close,
error_text: None,
close_code: Some(code),
close_reason: Some(reason),
was_clean: Some(was_clean),
}
}
pub fn socket_id(&self) -> u64 {
self.socket_id
}
pub fn document_url(&self) -> &Url {
&self.document_url
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn kind(&self) -> WebSocketLifecycleKind {
self.kind
}
pub fn error_text(&self) -> Option<&str> {
self.error_text.as_deref()
}
pub fn close_code(&self) -> Option<u16> {
self.close_code
}
pub fn close_reason(&self) -> Option<&str> {
self.close_reason.as_deref()
}
pub fn was_clean(&self) -> Option<bool> {
self.was_clean
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebSocketLifecycleKind {
Open,
Error,
Closing,
Close,
}
impl WebSocketLifecycleKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Error => "error",
Self::Closing => "closing",
Self::Close => "close",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebSocketFrameDirection {
Sent,
Received,
}
impl WebSocketFrameDirection {
pub fn as_str(self) -> &'static str {
match self {
Self::Sent => "sent",
Self::Received => "received",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebSocketFrameOpcode {
Text,
Binary,
}
impl WebSocketFrameOpcode {
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Binary => "binary",
}
}
}
/// Identifies the renderer realm that invoked one Runtime binding.
///
/// The realm generation is the renderer's opaque runtime-observable context
/// token, not a protocol execution-context id. It is only exact together with
/// the installed Page generation carried by the protocol prepared batch.
/// Keeping it on the call prevents a delayed consumer from reinterpreting the
/// call through whichever realm currently reuses the public context id.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RuntimeBindingCallSourceIdentity {
local_window_id: u64,
realm_generation: u64,
}
impl RuntimeBindingCallSourceIdentity {
pub const fn new(local_window_id: u64, realm_generation: u64) -> Self {
Self {
local_window_id,
realm_generation,
}
}
pub const fn local_window_id(self) -> u64 {
self.local_window_id
}
pub const fn realm_generation(self) -> u64 {
self.realm_generation
}
}
/// One Runtime binding invocation frozen at the renderer callback boundary.
///
/// `execution_context_id` is the public compatibility id emitted to CDP.
/// `source` is the opaque renderer identity that proves which realm produced
/// it; consumers must not recompute either value from current Page state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingRuntimeBindingCall {
pub source: RuntimeBindingCallSourceIdentity,
pub name: String,
pub payload: String,
pub execution_context_id: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeBindingRegistration {
/// Renderer DevTools session that owns this binding when it is part of an
/// aggregate Page snapshot. Session-local Inspector configuration leaves
/// this as `None` because the containing session is already authoritative.
pub devtools_session: Option<DevToolsSessionKey>,
pub name: String,
pub execution_context_name: Option<String>,
}
impl RuntimeBindingRegistration {
pub fn with_devtools_session(&self, devtools_session: DevToolsSessionKey) -> Self {
let mut binding = self.clone();
binding.devtools_session = Some(devtools_session);
binding
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RuntimeContextRestoreEvent {
Created(RuntimeExecutionContextRestoreEvent),
Destroyed(RuntimeExecutionContextRestoreEvent),
Cleared(RuntimeExecutionContextsClearedRestoreEvent),
}
impl RuntimeContextRestoreEvent {
pub fn from_v8_inspector_message(message: Value) -> Option<Self> {
match message["method"].as_str()? {
"Runtime.executionContextCreated" => Some(Self::Created(
RuntimeExecutionContextRestoreEvent::created_from_v8_inspector_params(
message["params"].clone(),
),
)),
"Runtime.executionContextDestroyed" => Some(Self::Destroyed(
RuntimeExecutionContextRestoreEvent::destroyed_from_v8_inspector_params(
message["params"].clone(),
),
)),
"Runtime.executionContextsCleared" => Some(Self::Cleared(
RuntimeExecutionContextsClearedRestoreEvent {},
)),
_ => None,
}
}
pub fn execution_context_created_id(&self) -> Option<i64> {
match self {
Self::Created(event) => event.context_id,
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeExecutionContextRestoreEvent {
pub context_id: Option<i64>,
pub realm_id: Option<String>,
pub frame_id: Option<String>,
pub origin: Option<String>,
pub name: Option<String>,
pub is_default: Option<bool>,
pub context_type: Option<String>,
pub grant_universal_access: Option<bool>,
}
impl RuntimeExecutionContextRestoreEvent {
fn created_from_v8_inspector_params(params: Value) -> Self {
let context = &params["context"];
let aux_data = &context["auxData"];
Self {
context_id: context["id"].as_i64(),
realm_id: context["uniqueId"].as_str().map(str::to_owned),
frame_id: aux_data["frameId"].as_str().map(str::to_owned),
origin: context["origin"].as_str().map(str::to_owned),
name: context["name"].as_str().map(str::to_owned),
is_default: aux_data["isDefault"].as_bool(),
context_type: aux_data["type"].as_str().map(str::to_owned),
grant_universal_access: aux_data["grantUniversalAccess"].as_bool(),
}
}
fn destroyed_from_v8_inspector_params(params: Value) -> Self {
Self {
context_id: params["executionContextId"].as_i64(),
realm_id: params["executionContextUniqueId"]
.as_str()
.map(str::to_owned),
frame_id: None,
origin: None,
name: None,
is_default: None,
context_type: None,
grant_universal_access: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RuntimeExecutionContextsClearedRestoreEvent {}
#[derive(Debug, Clone, PartialEq)]
pub struct PermissionOverrideRegistration {
pub permission: Value,
pub setting: String,
pub origin: Option<String>,
pub embedded_origin: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeIsolatedWorldDefinition {
pub name: String,
pub grant_universal_access: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EmulatedMediaOverrides {
pub media: Option<String>,
pub color_scheme: Option<String>,
pub reduced_motion: Option<String>,
pub reduced_transparency: Option<String>,
pub forced_colors: Option<String>,
pub contrast: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmulatedIdleOverride {
pub is_user_active: bool,
pub is_screen_unlocked: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportSurface {
pub inner_width: u32,
pub inner_height: u32,
pub outer_width: u32,
pub outer_height: u32,
pub device_pixel_ratio: f64,
pub screen_width: u32,
pub screen_height: u32,
pub screen_avail_width: u32,
pub screen_avail_height: u32,
pub window_x: i32,
pub window_y: i32,
pub screen_orientation: ScreenOrientation,
/// Presentation of the native widget, independent of the CSS layout size.
pub emulated_view: Option<EmulatedView>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EmulatedView {
pub width: u32,
pub height: u32,
pub scale: f64,
/// The compositor keeps its native DPR even when CSS reports an override.
pub native_device_pixel_ratio: f64,
pub viewport: Option<EmulatedViewport>,
}
/// Page-coordinate origin and scale of a forced compositor viewport. Its
/// requested extent is already accounted for in the native widget dimensions.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EmulatedViewport {
pub x: f64,
pub y: f64,
pub scale: f64,
}
impl ViewportSurface {
pub fn visible_size(self) -> (u32, u32) {
self.emulated_view
.map_or((self.inner_width, self.inner_height), |view| {
(view.width, view.height)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenOrientation {
pub kind: &'static str,
pub angle: u16,
}
impl Default for ScreenOrientation {
fn default() -> Self {
let profile = moli_browser_profile::DEFAULT_WINDOW_SURFACE_PROFILE;
Self {
kind: profile.orientation_type,
angle: profile.orientation_angle as u16,
}
}
}
impl Default for ViewportSurface {
fn default() -> Self {
let profile = moli_browser_profile::DEFAULT_WINDOW_SURFACE_PROFILE;
Self {
inner_width: profile.inner_width as u32,
inner_height: profile.inner_height as u32,
outer_width: profile.inner_width as u32,
outer_height: profile.inner_height as u32,
device_pixel_ratio: profile.device_pixel_ratio,
screen_width: profile.screen_width as u32,
screen_height: profile.screen_height as u32,
screen_avail_width: profile.screen_avail_width as u32,
screen_avail_height: profile.screen_avail_height as u32,
window_x: 0,
window_y: 0,
screen_orientation: Default::default(),
emulated_view: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ChildFrameTreeSnapshot {
pub frame_id: String,
#[serde(default)]
pub loader_id: String,
pub name: Option<String>,
#[serde(default)]
pub owner_element_id: Option<String>,
pub url: String,
#[serde(default)]
pub storage_key: String,
#[serde(default)]
pub security_origin_inherited: bool,
#[serde(default)]
pub security_origin_opaque: bool,
#[serde(default)]
pub child_frames: Vec<ChildFrameTreeSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameAttachmentSnapshot {
pub frame_id: String,
#[serde(default)]
pub parent_frame_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameDocumentOpenedSnapshot {
pub frame_id: String,
#[serde(default)]
pub parent_frame_id: Option<String>,
#[serde(default)]
pub loader_id: Option<String>,
pub name: Option<String>,
pub url: String,
#[serde(default)]
pub security_origin_inherited: bool,
#[serde(default)]
pub security_origin_opaque: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameDetachmentSnapshot {
pub frame_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum ChildFrameTreeEventSnapshot {
Attached(ChildFrameAttachmentSnapshot),
Detached(ChildFrameDetachmentSnapshot),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameNavigationSnapshot {
pub frame_id: String,
#[serde(default)]
pub parent_frame_id: Option<String>,
#[serde(default)]
pub loader_id: Option<String>,
pub name: Option<String>,
pub url: String,
#[serde(default)]
pub document_open_replacement: bool,
#[serde(default)]
pub security_origin_inherited: bool,
#[serde(default)]
pub security_origin_opaque: bool,
#[serde(default)]
pub document_network: Option<ChildFrameDocumentNetworkSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameDocumentNetworkSnapshot {
pub request_url: String,
pub request_method: String,
#[serde(default)]
pub request_headers: Vec<(String, String)>,
pub final_url: String,
pub status: u16,
#[serde(default)]
pub response_headers: Vec<(String, Vec<u8>)>,
#[serde(default)]
pub encoded_data_length: usize,
/// Exact in-process response body source for protocol consumers.
///
/// Renderer/protocol transport shares this carrier without copying the
/// complete payload. Serialized snapshots retain their historical wire
/// shape and therefore deserialize without a body source.
#[serde(skip)]
pub response_body: Option<SubresourceResponseBody>,
#[serde(default)]
pub from_cache: bool,
}
/// A completed child main-resource request whose Network facts remain
/// observable even though its navigation no longer owns the current child
/// Document.
///
/// Keeping this separate from `ChildFrameNavigationSnapshot` prevents a stale
/// response from synthesizing a navigation commit or lifecycle terminal.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChildFrameDocumentNetworkActivitySnapshot {
pub frame_id: String,
#[serde(default)]
pub parent_frame_id: Option<String>,
pub loader_id: String,
pub snapshot: ChildFrameDocumentNetworkSnapshot,
}
#[derive(Debug, Clone)]
pub struct ScriptRun {
node_id: NodeId,
kind: ScriptKind,
mode: ScriptMode,
source_kind: ScriptSourceKind,
url: Url,
outcome: ScriptRunOutcome,
}
impl ScriptRun {
pub fn executed(
node_id: NodeId,
kind: ScriptKind,
mode: ScriptMode,
source_kind: ScriptSourceKind,
url: Url,
) -> Self {
Self {
node_id,
kind,
mode,
source_kind,
url,
outcome: ScriptRunOutcome::Executed,
}
}
pub fn skipped(
node_id: NodeId,
kind: ScriptKind,
mode: ScriptMode,
source_kind: ScriptSourceKind,
url: Url,
reason: ScriptSkipReason,
) -> Self {
Self {
node_id,
kind,
mode,
source_kind,
url,
outcome: ScriptRunOutcome::Skipped(reason),
}
}
pub fn failed(
node_id: NodeId,
kind: ScriptKind,
mode: ScriptMode,
source_kind: ScriptSourceKind,
url: Url,
message: String,
) -> Self {
Self {
node_id,
kind,
mode,
source_kind,
url,
outcome: ScriptRunOutcome::Failed(message),
}
}
pub fn node_id(&self) -> NodeId {
self.node_id
}
pub fn kind(&self) -> ScriptKind {
self.kind
}
pub fn mode(&self) -> ScriptMode {
self.mode
}
pub fn source_kind(&self) -> ScriptSourceKind {
self.source_kind
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn outcome(&self) -> &ScriptRunOutcome {
&self.outcome
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum JsValueSnapshot {
Undefined,
Null,
Bool(bool),
Number(f64),
String(String),
Unsupported(String),
}
impl JsValueSnapshot {
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
_ => None,
}
}
pub fn as_number(&self) -> Option<f64> {
match self {
Self::Number(value) => Some(*value),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(value) | Self::Unsupported(value) => Some(value),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub enum ScriptRunOutcome {
Executed,
Skipped(ScriptSkipReason),
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptKind {
Classic,
Module,
ImportMap,
DataBlock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptMode {
Normal,
Defer,
ModuleDefer,
InOrder,
ImportMapInOrder,
ModuleInOrder,
Async,
}
impl ScriptMode {
pub fn is_defer_like(self) -> bool {
matches!(self, Self::Defer | Self::ModuleDefer)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScriptSourceKind {
Inline,
External,
}
impl ScriptSourceKind {
pub fn from_script_src(src: &Option<String>) -> Self {
if src.is_some() {
Self::External
} else {
Self::Inline
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptSkipReason {
NotInMainDocument,
AlreadyStarted,
ScriptExecutionDisabled,
EmptyInlineScript,
NoModule,
ModuleNotSupported,
ImportMapNotSupported,
UnsupportedType(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn test_url(path: &str) -> Url {
Url::parse(&format!("https://example.test{path}")).expect("test URL should parse")
}
fn pooled_body_writer(memory_limit: usize, pool: &DiskPool) -> SubresourceResponseBodyWriter {
SubresourceResponseBodyWriter::with_memory_limit_and_disk_pool(
memory_limit,
Some(pool.clone()),
)
}
#[test]
fn navigation_response_byte_parts_transfer_exact_storage() {
let template =
NavigationResponse::from_text_body(test_url("/module"), 200, Vec::new(), String::new());
for bytes in [b"module source".to_vec(), vec![b'a', 0xff, b'b']] {
let expected = bytes.clone();
let storage = bytes.as_ptr();
let response = NavigationResponse::from_head_and_materialized_body(
template.head(),
ResponseBody::lossy_text_from_bytes(bytes),
);
let (head, bytes) = response.into_byte_parts();
assert_eq!(head.final_url, template.final_url);
assert_eq!(bytes, expected);
assert_eq!(bytes.as_ptr(), storage, "exact bytes must be transferred");
}
}
#[test]
fn script_execution_report_exposes_globals_snapshot_freshness() {
let mut report = ScriptExecutionReport::default();
assert_eq!(
report.globals_snapshot_state(),
ScriptGlobalsSnapshotState::Uncaptured
);
assert_eq!(report.fresh_globals(), None);
assert!(!report.mark_globals_snapshot_dirty());
let mut globals = BTreeMap::new();
globals.insert("answer".to_owned(), JsValueSnapshot::Number(42.0));
assert!(report.replace_globals_snapshot(globals));
assert_eq!(
report
.fresh_globals()
.and_then(|values| values.get("answer")),
Some(&JsValueSnapshot::Number(42.0))
);
let cloned = report.clone();
assert!(Arc::ptr_eq(&report.globals, &cloned.globals));
assert!(report.mark_globals_snapshot_dirty());
assert!(!report.mark_globals_snapshot_dirty());
assert_eq!(
report.globals_snapshot_state(),
ScriptGlobalsSnapshotState::Dirty
);
assert!(report.fresh_globals().is_none());
assert_eq!(
report.global("answer"),
Some(&JsValueSnapshot::Number(42.0)),
"the compatibility accessor must retain the last complete snapshot"
);
assert!(report.replace_globals_snapshot(BTreeMap::new()));
assert!(report.globals_are_fresh());
assert!(report.globals().is_empty());
}
#[test]
fn subresource_network_record_clones_share_until_modified() {
let record = SubresourceNetworkRecord::failure(
Some("FRAME".to_owned()),
test_url("/"),
test_url("/asset.js"),
"GET".to_owned(),
vec![("accept".to_owned(), "*/*".to_owned())].into(),
None,
SubresourceResourceType::Script,
"network failed".to_owned(),
);
let cloned = record.clone();
assert!(Arc::ptr_eq(&record.inner, &cloned.inner));
let modified = cloned.with_from_cache(true);
assert!(!Arc::ptr_eq(&record.inner, &modified.inner));
assert!(!record.from_cache());
assert!(modified.from_cache());
}
#[test]
fn scroll_into_view_rect_only_accepts_finite_protocol_numbers() {
assert_eq!(
DomScrollIntoViewRect::try_new(1.0, 2.0, -3.0, 4.0),
Some(DomScrollIntoViewRect {
x: 1.0,
y: 2.0,
width: -3.0,
height: 4.0,
})
);
for components in [
[f64::NAN, 0.0, 0.0, 0.0],
[0.0, f64::INFINITY, 0.0, 0.0],
[0.0, 0.0, f64::NEG_INFINITY, 0.0],
[0.0, 0.0, 0.0, f64::NAN],
] {
assert!(
DomScrollIntoViewRect::try_new(
components[0],
components[1],
components[2],
components[3],
)
.is_none()
);
}
}
#[test]
fn optional_resource_fetch_mask_assigns_one_stable_bit_per_resource_family() {
let resources = [
(
OptionalResourceFetchMask::IMAGE,
1 << 0,
SubresourceResourceType::Image,
),
(
OptionalResourceFetchMask::FONT,
1 << 1,
SubresourceResourceType::Font,
),
(
OptionalResourceFetchMask::AUDIO,
1 << 2,
SubresourceResourceType::Audio,
),
(
OptionalResourceFetchMask::VIDEO,
1 << 3,
SubresourceResourceType::Video,
),
(
OptionalResourceFetchMask::MEDIA,
1 << 4,
SubresourceResourceType::Media,
),
(
OptionalResourceFetchMask::TEXT_TRACK,
1 << 5,
SubresourceResourceType::TextTrack,
),
];
assert_eq!(
OptionalResourceFetchMask::default(),
OptionalResourceFetchMask::NONE
);
assert!(OptionalResourceFetchMask::NONE.is_empty());
assert_eq!(OptionalResourceFetchMask::ALL.bits(), 0b00_111111);
for (index, (mask, expected_bit, resource_type)) in resources.iter().enumerate() {
assert_eq!(mask.bits(), *expected_bit);
assert_eq!(
OptionalResourceFetchMask::for_resource_type(*resource_type),
Some(*mask)
);
for (other_index, (other, _, _)) in resources.iter().enumerate() {
assert_eq!(
mask.contains(*other),
index == other_index,
"{resource_type:?} unexpectedly shared a bit"
);
}
}
}
#[test]
fn cdp_fetch_interception_groups_fetch_event_source_and_xhr() {
let fetch_like = [
SubresourceResourceType::Fetch,
SubresourceResourceType::EventSource,
SubresourceResourceType::Xhr,
];
for resource_type in fetch_like {
assert_eq!(resource_type.as_cdp_fetch_interception_type(), "XHR");
for other in fetch_like {
assert!(resource_type.has_same_cdp_fetch_interception_type(other));
}
assert!(
!resource_type
.has_same_cdp_fetch_interception_type(SubresourceResourceType::Script)
);
}
assert_eq!(SubresourceResourceType::Fetch.as_cdp_type(), "Fetch");
assert_eq!(
SubresourceResourceType::EventSource.as_cdp_type(),
"EventSource"
);
assert_eq!(SubresourceResourceType::Xhr.as_cdp_type(), "XHR");
}
#[test]
fn optional_resource_fetch_mask_round_trips_valid_bits_and_rejects_unknown_bits() {
for bits in 0..=OptionalResourceFetchMask::ALL.bits() {
assert_eq!(
OptionalResourceFetchMask::from_bits(bits).map(|mask| mask.bits()),
Some(bits)
);
}
for bits in [
1 << 6,
1 << 7,
OptionalResourceFetchMask::ALL.bits() | (1 << 6),
u8::MAX,
] {
assert_eq!(OptionalResourceFetchMask::from_bits(bits), None);
}
}
#[test]
fn optional_resource_fetch_mask_allows_only_enabled_optional_families() {
let optional = [
(
SubresourceResourceType::Image,
OptionalResourceFetchMask::IMAGE,
),
(
SubresourceResourceType::Font,
OptionalResourceFetchMask::FONT,
),
(
SubresourceResourceType::Audio,
OptionalResourceFetchMask::AUDIO,
),
(
SubresourceResourceType::Video,
OptionalResourceFetchMask::VIDEO,
),
(
SubresourceResourceType::Media,
OptionalResourceFetchMask::MEDIA,
),
(
SubresourceResourceType::TextTrack,
OptionalResourceFetchMask::TEXT_TRACK,
),
];
let required = [
SubresourceResourceType::Script,
SubresourceResourceType::Stylesheet,
SubresourceResourceType::Fetch,
SubresourceResourceType::EventSource,
SubresourceResourceType::Xhr,
SubresourceResourceType::Ping,
SubresourceResourceType::CspReport,
SubresourceResourceType::Dictionary,
SubresourceResourceType::WebSocket,
];
for resource_type in required {
assert!(OptionalResourceFetchMask::NONE.allows(resource_type));
}
for (resource_type, bit) in optional {
assert!(!OptionalResourceFetchMask::NONE.allows(resource_type));
assert!(bit.allows(resource_type));
assert!(OptionalResourceFetchMask::ALL.allows(resource_type));
}
}
#[test]
fn optional_resource_fetch_mask_combines_and_removes_independent_bits() {
let mut mask = OptionalResourceFetchMask::IMAGE | OptionalResourceFetchMask::AUDIO;
mask |= OptionalResourceFetchMask::TEXT_TRACK;
assert_eq!(
mask.bits(),
OptionalResourceFetchMask::IMAGE.bits()
| OptionalResourceFetchMask::AUDIO.bits()
| OptionalResourceFetchMask::TEXT_TRACK.bits()
);
mask.set(OptionalResourceFetchMask::AUDIO, false);
mask.set(OptionalResourceFetchMask::FONT, true);
assert!(mask.contains(OptionalResourceFetchMask::IMAGE));
assert!(mask.contains(OptionalResourceFetchMask::FONT));
assert!(!mask.contains(OptionalResourceFetchMask::AUDIO));
assert!(mask.contains(OptionalResourceFetchMask::TEXT_TRACK));
assert_eq!(
mask & OptionalResourceFetchMask::ALL,
mask,
"intersection with ALL should preserve every enabled bit"
);
}
#[test]
fn script_execution_report_extends_network_output_as_one_batch() {
let document_url = test_url("/");
let record = SubresourceNetworkRecord::failure(
Some("FRAME".to_owned()),
document_url.clone(),
test_url("/api"),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
"network failed".to_owned(),
);
let websocket_event = WebSocketNetworkEvent::new(
7,
document_url.clone(),
test_url("/socket"),
WebSocketFrameDirection::Received,
WebSocketFrameOpcode::Text,
5,
);
let lifecycle_event =
WebSocketLifecycleEvent::open(7, document_url.clone(), test_url("/socket"));
let output = ScriptNetworkOutput::from_items([
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record.clone())),
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event.clone()),
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event.clone()),
]);
assert!(!output.is_empty());
let mut report = ScriptExecutionReport::default();
report.extend_network_output(output);
assert_eq!(
report.subresource_network_records(),
std::slice::from_ref(&record)
);
assert_eq!(
report.websocket_network_events(),
std::slice::from_ref(&websocket_event)
);
assert_eq!(
report.websocket_lifecycle_events(),
std::slice::from_ref(&lifecycle_event)
);
}
#[test]
fn staged_subresource_network_output_materializes_settled_report_record() {
let handle = SubresourceNetworkRequestHandle::new(42);
let document_url = test_url("/");
let request = SubresourceRequestStarted::new(
handle,
Some("FRAME".to_owned()),
document_url,
test_url("/image.png"),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Image,
SubresourceRequestInitiatorType::Parser,
None,
);
let response = SubresourceResponseStarted::new(
handle,
Vec::new(),
test_url("/image.png"),
200,
vec![("content-type".to_owned(), b"image/png".to_vec())],
Vec::new(),
);
let body = SubresourceBodyFinished::ready(
handle,
SubresourceResponseBody::from_bytes(vec![1, 2, 3]),
);
let mut report = ScriptExecutionReport::default();
report.extend_network_output(ScriptNetworkOutput::from_items([
ScriptNetworkOutputItem::SubresourceRequestStarted(Box::new(request.clone())),
]));
report.extend_network_output(ScriptNetworkOutput::from_items([
ScriptNetworkOutputItem::SubresourceResponseStarted(Box::new(response.clone())),
]));
assert!(report.subresource_network_records().is_empty());
report.extend_network_output(ScriptNetworkOutput::from_items([
ScriptNetworkOutputItem::SubresourceBodyFinished(Box::new(body.clone())),
]));
let [record] = report.subresource_network_records() else {
panic!("settled staged lifecycle should materialize one report record")
};
assert_eq!(record.request_handle(), Some(handle));
assert_eq!(
record.request_initiator_type(),
SubresourceRequestInitiatorType::Parser
);
let SubresourceNetworkOutcome::Success {
status,
response_body,
..
} = record.outcome()
else {
panic!("settled staged response should remain successful")
};
assert_eq!(*status, 200);
assert_eq!(response_body.try_bytes().unwrap().as_ref(), &[1, 2, 3]);
}
#[test]
fn script_network_output_items_preserve_explicit_append_order() {
let document_url = test_url("/");
let record = SubresourceNetworkRecord::failure(
Some("FRAME".to_owned()),
document_url.clone(),
test_url("/api"),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
"network failed".to_owned(),
);
let websocket_event = WebSocketNetworkEvent::new(
7,
document_url.clone(),
test_url("/socket"),
WebSocketFrameDirection::Received,
WebSocketFrameOpcode::Text,
5,
);
let lifecycle_event =
WebSocketLifecycleEvent::open(7, document_url.clone(), test_url("/socket"));
let output = ScriptNetworkOutput::from_items([
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event.clone()),
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record.clone())),
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event.clone()),
]);
let items: Vec<_> = output.into_items().collect();
assert_eq!(
items,
vec![
ScriptNetworkOutputItem::WebSocketLifecycleEvent(lifecycle_event),
ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record)),
ScriptNetworkOutputItem::WebSocketNetworkEvent(websocket_event),
],
"script network output iteration should preserve explicit producer append order"
);
}
#[test]
fn script_observable_output_items_preserve_batch_append_order() {
let output = ScriptObservableOutput::from_items([
ScriptObservableOutputItem::LifecycleError("warn-a".to_owned()),
ScriptObservableOutputItem::ConsoleMessage("console-a".to_owned()),
ScriptObservableOutputItem::LifecycleError("warn-b".to_owned()),
]);
let mut report = ScriptExecutionReport::default();
report.extend_observable_output(output);
assert_eq!(report.console_messages(), &["console-a".to_owned()]);
assert_eq!(
report.lifecycle_errors(),
&["warn-a".to_owned(), "warn-b".to_owned()]
);
assert_eq!(
report.observable_output_items(),
&[
ScriptObservableOutputItem::LifecycleError("warn-a".to_owned()),
ScriptObservableOutputItem::ConsoleMessage("console-a".to_owned()),
ScriptObservableOutputItem::LifecycleError("warn-b".to_owned()),
],
"report should keep observable producer item order for later core/CDP queues"
);
}
#[test]
fn subresource_response_body_stores_only_exact_response_bytes() {
let response = Response::from_head_and_body(
ResponseHead {
final_url: test_url("/utf8.txt"),
status: 200,
headers: Vec::new(),
request_cookie_report: None,
cookie_set_reports: Vec::new(),
redirected: false,
redirect_chain: Vec::new(),
from_cache: false,
negotiated_http_version: None,
},
"hello".to_owned(),
b"hello".to_vec(),
);
let body = SubresourceResponseBody::from_fetch_response(&response);
let SubresourceResponseBodyInner::Memory(bytes) = body.inner.as_ref() else {
panic!("fetch response should use in-memory byte storage");
};
assert_eq!(bytes, b"hello");
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello");
}
#[test]
fn subresource_response_body_preserves_non_utf8_response_bytes() {
let response = Response::from_head_and_body(
ResponseHead {
final_url: test_url("/legacy.txt"),
status: 200,
headers: Vec::new(),
request_cookie_report: None,
cookie_set_reports: Vec::new(),
redirected: false,
redirect_chain: Vec::new(),
from_cache: false,
negotiated_http_version: None,
},
"é".to_owned(),
vec![0xe9],
);
let body = SubresourceResponseBody::from_fetch_response(&response);
assert_eq!(body.try_bytes().unwrap().as_ref(), &[0xe9]);
assert_eq!(String::from_utf8_lossy(&body.try_bytes().unwrap()), "�");
}
#[test]
fn subresource_response_body_writer_keeps_small_body_in_memory() {
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(16, &pool);
writer.append(b"hello");
writer.append(b" world");
let body = writer.finish();
assert_eq!(body.len(), 11);
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello world");
assert_eq!(body.clone_body_bytes(), b"hello world");
assert_eq!(pool.diagnostics().disk_footprint_bytes, 0);
}
#[test]
fn navigation_response_body_can_move_into_an_external_backing_without_cloning() {
let mut response = NavigationResponse::from_head_and_body(
ResponseHead {
final_url: test_url("/image.png"),
status: 200,
headers: vec![("Content-Type".to_owned(), b"image/png".to_vec())],
request_cookie_report: None,
cookie_set_reports: Vec::new(),
redirected: false,
redirect_chain: Vec::new(),
from_cache: false,
negotiated_http_version: None,
},
String::new(),
b"encoded-image".to_vec(),
);
assert_eq!(response.take_body_bytes(), b"encoded-image");
assert!(response.body_bytes().is_empty());
assert_eq!(response.status, 200);
assert_eq!(response.final_url, test_url("/image.png"));
}
#[test]
fn subresource_response_body_writer_pools_large_body_and_materializes_on_demand() {
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(4, &pool);
writer.append(b"hello");
writer.append(b" world");
let body = writer.finish();
assert_eq!(body.len(), 11);
assert_eq!(pool.diagnostics().disk_footprint_bytes, 11);
assert_eq!(body.try_bytes().unwrap().as_ref(), b"hello world");
let mut copied = Vec::new();
body.write_bytes_to(&mut copied)
.expect("pooled body should stream-copy into writer");
assert_eq!(copied, b"hello world");
}
#[test]
fn pooled_response_body_releases_the_empty_staging_allocation() {
let memory_limit = 1024 * 1024;
let bytes = vec![7; memory_limit + 1];
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(memory_limit, &pool);
writer.append(&bytes);
let body = writer.finish();
assert_eq!(body.len(), bytes.len());
assert_eq!(body.materialize_bytes().unwrap(), bytes);
assert!(
body.renderer_transport_retained_memory_bytes() < memory_limit,
"a disk-backed body must not retain its full staging allocation"
);
}
#[test]
fn subresource_response_body_reads_memory_and_pooled_chunks_by_offset() {
let memory = SubresourceResponseBody::from_bytes(b"hello world".to_vec());
assert_eq!(memory.read_chunk(6, 3).unwrap(), b"wor");
assert_eq!(memory.materialize_bytes_from(6).unwrap(), b"world");
assert_eq!(memory.diagnostic_clone_body_bytes_from(6), b"world");
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(4, &pool);
writer.append(b"hello");
writer.append(b" world");
let pooled = writer.finish();
assert_eq!(pooled.read_chunk(3, 5).unwrap(), b"lo wo");
assert_eq!(pooled.read_chunk(6, 3).unwrap(), b"wor");
assert_eq!(pooled.materialize_bytes_from(6).unwrap(), b"world");
assert_eq!(pooled.diagnostic_clone_body_bytes_from(6), b"world");
}
#[test]
fn subresource_response_bodies_share_pool_and_release_extents_on_last_drop() {
let pool = DiskPool::new(None).unwrap();
let mut first_writer = pooled_body_writer(4, &pool);
first_writer.append(b"abcdefgh");
let first = first_writer.finish();
let mut second_writer = pooled_body_writer(4, &pool);
second_writer.append(b"ijklmnop");
let second = second_writer.finish();
assert_eq!(pool.diagnostics().disk_footprint_bytes, 16);
assert_eq!(pool.diagnostics().free_bytes, 0);
let first_clone = first.clone();
drop(first);
assert_eq!(
pool.diagnostics().free_bytes,
0,
"the body Arc should own its pooled extents"
);
drop(first_clone);
assert_eq!(pool.diagnostics().free_bytes, 8);
drop(second);
assert_eq!(pool.diagnostics().free_bytes, 16);
assert_eq!(pool.diagnostics().free_chunk_count, 1);
}
#[test]
fn parkable_image_body_shares_one_extent_and_unparks_on_demand() {
let pool = DiskPool::new(None).unwrap();
let manager = moli_parkable_image::ParkableImageManager::new(
Some(pool.clone()),
moli_parkable_image::ParkableImagePolicy {
min_size_to_park: 1,
parking_delay: std::time::Duration::ZERO,
reader_release_delay: std::time::Duration::ZERO,
},
);
let encoded = manager.from_frozen_bytes(b"encoded image".to_vec());
let body = SubresourceResponseBody::from_parkable_image(encoded.clone());
manager.force_park_images();
assert_eq!(manager.diagnostics().parked_count, 1);
assert_eq!(pool.diagnostics().disk_footprint_bytes, 13);
assert_eq!(body.read_chunk(8, 5).unwrap(), b"image");
assert_eq!(body.materialize_bytes().unwrap(), b"encoded image");
assert_eq!(pool.diagnostics().disk_footprint_bytes, 13);
drop(encoded);
assert_eq!(pool.diagnostics().free_bytes, 0);
drop(body);
assert_eq!(pool.diagnostics().free_bytes, 13);
}
#[test]
fn subresource_response_body_keeps_order_when_pool_reaches_capacity() {
let pool = DiskPool::new(Some(4)).unwrap();
let mut writer = pooled_body_writer(4, &pool);
writer.append(b"hello world");
let body = writer.finish();
assert_eq!(pool.diagnostics().disk_footprint_bytes, 4);
assert_eq!(body.materialize_bytes().unwrap(), b"hello world");
assert_eq!(body.read_chunk(3, 6).unwrap(), b"lo wor");
}
#[test]
fn subresource_response_body_fallible_materialize_reports_pool_corruption() {
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(2, &pool);
writer.append(b"hello");
let body = writer.finish();
pool.truncate_file_for_test(0).unwrap();
assert!(body.materialize_bytes().is_err());
assert!(body.materialize_bytes_from(2).is_err());
assert!(body.try_bytes().is_err());
assert_eq!(body.diagnostic_clone_body_bytes(), Vec::<u8>::new());
assert_eq!(body.diagnostic_bytes().as_ref(), &[] as &[u8]);
let record = SubresourceNetworkRecord::success_with_body(
None,
Url::parse("https://example.test/page").unwrap(),
Url::parse("https://example.test/api").unwrap(),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
None,
Vec::new(),
Url::parse("https://example.test/api").unwrap(),
200,
vec![("content-type".to_owned(), b"text/plain".to_vec())],
body,
Vec::new(),
);
let criteria = SubresourceResponseWaitCriteria {
body_regex: Some(Regex::new("missing").unwrap()),
..SubresourceResponseWaitCriteria::default()
};
assert!(
criteria.try_matches(&record).is_err(),
"production wait criteria should surface body source read errors"
);
assert!(
!criteria.diagnostic_matches(&record),
"diagnostic compatibility matcher should degrade unreadable body to no match"
);
}
#[test]
fn url_wait_criteria_matches_original_and_final_urls_as_regex() {
let record = SubresourceNetworkRecord::success(
None,
Url::parse("https://example.test/page").unwrap(),
Url::parse("https://api.example.test/v1/orders/42").unwrap(),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
None,
Vec::new(),
Url::parse("https://api.example.test/v2/orders/42").unwrap(),
200,
Vec::new(),
String::new(),
Vec::new(),
);
for regex in [r"/v1/orders/\d+$", r"/v2/orders/\d+$"] {
let criteria = SubresourceResponseWaitCriteria {
url_regex: Some(Regex::new(regex).unwrap()),
..SubresourceResponseWaitCriteria::default()
};
assert!(criteria.try_matches(&record).is_ok_and(|matches| matches));
}
let non_matching = SubresourceResponseWaitCriteria {
url_regex: Some(Regex::new(r"/v3/orders/\d+$").unwrap()),
..SubresourceResponseWaitCriteria::default()
};
assert!(
non_matching
.try_matches(&record)
.is_ok_and(|matches| !matches)
);
}
#[test]
fn body_wait_criteria_supports_literal_and_regex_matching() {
let record = SubresourceNetworkRecord::success_with_body(
None,
Url::parse("https://example.test/page").unwrap(),
Url::parse("https://example.test/api").unwrap(),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
None,
Vec::new(),
Url::parse("https://example.test/api").unwrap(),
200,
vec![("content-type".to_owned(), b"text/plain".to_vec())],
SubresourceResponseBody::from_bytes(b"order #42 ready".to_vec()),
Vec::new(),
);
let literal = SubresourceResponseWaitCriteria {
body_contains: Some("order #42 ready".to_owned()),
..SubresourceResponseWaitCriteria::default()
};
assert!(literal.try_matches(&record).is_ok_and(|matches| matches));
let regex_syntax_is_literal = SubresourceResponseWaitCriteria {
body_contains: Some(r"order #\d+ ready".to_owned()),
..SubresourceResponseWaitCriteria::default()
};
assert!(
regex_syntax_is_literal
.try_matches(&record)
.is_ok_and(|matches| !matches)
);
let criteria = SubresourceResponseWaitCriteria {
body_regex: Some(Regex::new(r"order #\d+ ready").unwrap()),
..SubresourceResponseWaitCriteria::default()
};
assert!(criteria.try_matches(&record).is_ok_and(|matches| matches));
let non_matching = SubresourceResponseWaitCriteria {
body_regex: Some(Regex::new(r"^order #\d{3} ready$").unwrap()),
..SubresourceResponseWaitCriteria::default()
};
assert!(
non_matching
.try_matches(&record)
.is_ok_and(|matches| !matches)
);
}
#[test]
fn json_path_wait_criteria_supports_equals_and_regex_with_shared_mime_parser() {
let expectation = SubresourceJsonPathEquals {
path: vec!["ok".to_owned()],
expected: "true".to_owned(),
};
assert!(json_path_equals(
&[(
"content-type".to_owned(),
b"application/manifest+json;charset=utf-8".to_vec(),
)],
r#"{"ok":true}"#,
&expectation,
));
assert!(json_path_equals(
&[(
"content-type".to_owned(),
b"application/json; charset = utf-8".to_vec(),
)],
r#"{"ok":true}"#,
&expectation,
));
assert!(!json_path_equals(
&[("content-type".to_owned(), b"text/json".to_vec())],
r#"{"ok":true}"#,
&expectation,
));
let regex_expectation = SubresourceJsonPathRegex {
path: vec!["data".to_owned(), "url".to_owned()],
regex: Regex::new(r"^/item/\d+$").unwrap(),
};
assert!(json_path_matches_regex(
&[(
"content-type".to_owned(),
b"application/json; charset=utf-8".to_vec(),
)],
r#"{"data":{"url":"/item/42"}}"#,
&regex_expectation,
));
assert!(!json_path_matches_regex(
&[("content-type".to_owned(), b"application/json".to_vec())],
r#"{"data":{"url":"/orders/42"}}"#,
&regex_expectation,
));
}
#[test]
fn subresource_response_body_partial_eq_does_not_treat_unreadable_sources_as_empty() {
let pool = DiskPool::new(None).unwrap();
let mut writer_a = pooled_body_writer(2, &pool);
writer_a.append(b"first");
let body_a = writer_a.finish();
let mut writer_b = pooled_body_writer(2, &pool);
writer_b.append(b"other");
let body_b = writer_b.finish();
pool.truncate_file_for_test(0).unwrap();
assert_eq!(body_a, body_a.clone());
assert_ne!(body_a, body_b);
assert!(!body_a.diagnostic_byte_eq(&body_b));
}
#[test]
fn subresource_response_body_partial_eq_keeps_disk_backed_equality_io_free() {
let pool = DiskPool::new(None).unwrap();
let mut left_writer = pooled_body_writer(2, &pool);
left_writer.append(b"same");
let left = left_writer.finish();
let mut right_writer = pooled_body_writer(2, &pool);
right_writer.append(b"same");
let right = right_writer.finish();
assert_ne!(
left, right,
"ordinary equality should not read separate disk-backed sources"
);
assert!(
left.try_byte_eq(&right)
.expect("explicit byte equality should read pooled bodies"),
"callers that need content equality must opt in to source I/O"
);
}
#[test]
fn subresource_network_record_response_body_byte_equality_is_explicit() {
let pool = DiskPool::new(None).unwrap();
let mut left_writer = pooled_body_writer(2, &pool);
left_writer.append(b"same");
let left_body = left_writer.finish();
let mut right_writer = pooled_body_writer(2, &pool);
right_writer.append(b"same");
let right_body = right_writer.finish();
let left_record = SubresourceNetworkRecord::success_with_body(
None,
Url::parse("https://example.test/page").unwrap(),
Url::parse("https://example.test/api").unwrap(),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
None,
Vec::new(),
Url::parse("https://example.test/api").unwrap(),
200,
Vec::new(),
left_body,
Vec::new(),
);
let right_record = SubresourceNetworkRecord::success_with_body(
None,
Url::parse("https://example.test/page").unwrap(),
Url::parse("https://example.test/api").unwrap(),
"GET".to_owned(),
Vec::new().into(),
None,
SubresourceResourceType::Fetch,
None,
Vec::new(),
Url::parse("https://example.test/api").unwrap(),
200,
Vec::new(),
right_body,
Vec::new(),
);
assert_ne!(
left_record, right_record,
"ordinary record equality should not read independent body sources"
);
assert!(
left_record
.try_response_body_byte_eq(&right_record)
.expect("explicit record body equality should read pooled bodies"),
"callers that need record body content equality must opt in"
);
}
#[test]
fn subresource_response_body_preserves_invalid_bytes_when_pooled() {
let pool = DiskPool::new(None).unwrap();
let mut writer = pooled_body_writer(2, &pool);
writer.append(b"hi ");
writer.append(&[0xff, b'!']);
let body = writer.finish();
assert_eq!(
body.materialize_bytes().unwrap(),
&[b'h', b'i', b' ', 0xff, b'!']
);
}
#[test]
fn auth_challenge_parser_prefers_supported_scheme_from_later_header() {
let challenge = extract_subresource_auth_challenge(&[
(
"www-authenticate".to_owned(),
b"Bearer realm=\"token-area\"".to_vec(),
),
(
"www-authenticate".to_owned(),
b"Basic realm=\"basic-area\"".to_vec(),
),
])
.expect("auth challenge");
assert_eq!(challenge.source, "Server");
assert_eq!(challenge.scheme, "basic");
assert_eq!(challenge.realm, "basic-area");
}
#[test]
fn auth_challenge_parser_prefers_supported_scheme_from_combined_header() {
let challenge = extract_subresource_auth_challenge(&[(
"www-authenticate".to_owned(),
b"Bearer realm=\"token-area\", Basic realm=\"basic-area\"".to_vec(),
)])
.expect("auth challenge");
assert_eq!(challenge.source, "Server");
assert_eq!(challenge.scheme, "basic");
assert_eq!(challenge.realm, "basic-area");
}
#[test]
fn auth_challenge_parser_preserves_quoted_commas_and_escaped_quotes() {
let challenge = extract_subresource_auth_challenge(&[(
"www-authenticate".to_owned(),
br#"Digest realm="area, with \"quote\"", nonce="deadbeef", qop="auth""#.to_vec(),
)])
.expect("auth challenge");
assert_eq!(challenge.source, "Server");
assert_eq!(challenge.scheme, "digest");
assert_eq!(challenge.realm, "area, with \"quote\"");
}
#[test]
fn auth_challenge_parser_recognizes_proxy_and_falls_back_to_unsupported() {
let challenge = extract_subresource_auth_challenge(&[(
"proxy-authenticate".to_owned(),
b"Bearer realm=\"proxy-token\"".to_vec(),
)])
.expect("proxy auth challenge");
assert_eq!(challenge.source, "Proxy");
assert_eq!(challenge.scheme, "bearer");
assert_eq!(challenge.realm, "proxy-token");
}
#[test]
fn auth_credentials_map_proxy_basic_to_proxy_header_and_digest_to_proxy_auth() {
let basic = subresource_auth_credentials_for_challenge(
&SubresourceAuthChallenge {
source: "Proxy".to_owned(),
scheme: "Basic".to_owned(),
realm: "proxy".to_owned(),
},
"user",
"pass",
)
.expect("basic credentials");
assert_eq!(basic.target, SubresourceAuthTarget::ProxyHeader);
assert_eq!(basic.scheme, SubresourceAuthScheme::Basic);
let digest = subresource_auth_credentials_for_challenge(
&SubresourceAuthChallenge {
source: "Proxy".to_owned(),
scheme: "digest".to_owned(),
realm: "proxy".to_owned(),
},
"user",
"pass",
)
.expect("digest credentials");
assert_eq!(digest.target, SubresourceAuthTarget::Proxy);
assert_eq!(digest.scheme, SubresourceAuthScheme::Digest);
}
}