mirror of
https://github.com/lexmount/moli.git
synced 2026-11-15 00:08:03 +00:00
fix(performance): record Window fetch resource timing
Retain request-time script attribution and publish Fetch entries at buffered or streaming body completion. Apply CORS and Timing-Allow-Origin visibility independently, and cover network failures, async provenance, lazy publication and cache states.
This commit is contained in:
@@ -96,21 +96,28 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses()
|
||||
let browser = Browser::new(config)?;
|
||||
let mut page = browser.fetch(&format!("http://{addr}/")).await?;
|
||||
let fixture = include_str!("../fixtures/cache-resource-timing.js");
|
||||
for context in ["top", "child"] {
|
||||
for (context, transport) in [
|
||||
("top", "xhr"),
|
||||
("child", "xhr"),
|
||||
("top", "fetch"),
|
||||
("child", "fetch"),
|
||||
] {
|
||||
let fixture = format!(
|
||||
"(() => {{globalThis.cacheTimingContext = '{context}-{transport}';globalThis.cacheTimingTransport = '{transport}';return {fixture};}})()"
|
||||
);
|
||||
let script = if context == "top" {
|
||||
fixture.to_owned()
|
||||
fixture.clone()
|
||||
} else {
|
||||
format!(
|
||||
r#"(async () => {{
|
||||
const frame = document.createElement('iframe');
|
||||
frame.srcdoc = '<!doctype html><body>child';
|
||||
await new Promise(resolve => {{ frame.onload=resolve; document.body.append(frame); }});
|
||||
frame.contentWindow.cacheTimingContext = 'child';
|
||||
const result = await frame.contentWindow.eval({});
|
||||
frame.remove();
|
||||
return result;
|
||||
}})()"#,
|
||||
serde_json::to_string(fixture)?
|
||||
serde_json::to_string(&fixture)?
|
||||
)
|
||||
};
|
||||
let result = tokio::time::timeout(
|
||||
@@ -155,7 +162,7 @@ async fn resource_timing_distinguishes_network_local_and_revalidated_responses()
|
||||
{"padding":0,"transfer":306,"encoded":6},
|
||||
{"padding":4096,"transfer":306,"encoded":6}
|
||||
]}),
|
||||
"{context}"
|
||||
"{context}, {transport}"
|
||||
);
|
||||
}
|
||||
let _ = shutdown.send(());
|
||||
|
||||
+4
-2
@@ -7,6 +7,8 @@
|
||||
request.send();
|
||||
});
|
||||
const context = globalThis.cacheTimingContext || 'top';
|
||||
const request = globalThis.cacheTimingTransport === 'fetch'
|
||||
? url => fetch(url).then(response => response.text()) : xhr;
|
||||
const resourceUrl = (path, parameters) => {
|
||||
const url = new URL(path, document.baseURI);
|
||||
url.search = new URLSearchParams({...parameters, context});
|
||||
@@ -17,7 +19,7 @@
|
||||
const url = resourceUrl('/probe-cache', {mode});
|
||||
const observations = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const body = await xhr(url);
|
||||
const body = await request(url);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const entries = performance.getEntriesByName(url);
|
||||
const entry = entries[entries.length - 1];
|
||||
@@ -36,7 +38,7 @@
|
||||
const headers = [];
|
||||
for (const padding of [0, 4096]) {
|
||||
const url = resourceUrl('/probe-cache', {mode: 'padding', padding});
|
||||
await xhr(url);
|
||||
await request(url);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const entry = performance.getEntriesByName(url)[0];
|
||||
headers.push({padding, transfer: entry.transferSize, encoded: entry.encodedBodySize});
|
||||
|
||||
@@ -447,10 +447,10 @@ pub(crate) use self::notification_runtime::{
|
||||
notification_options_payload,
|
||||
};
|
||||
pub(crate) use self::performance_runtime::{
|
||||
ResourcePerformanceEntry, bind_document_performance, bind_window_performance_seed,
|
||||
current_performance_time_origin, increment_performance_event_count,
|
||||
project_document_lifecycle_timing, record_resource_performance_entry,
|
||||
run_resource_timing_buffer_full_task,
|
||||
FetchResourceTiming, ResourcePerformanceEntry, bind_document_performance,
|
||||
bind_window_performance_seed, current_performance_time_origin,
|
||||
increment_performance_event_count, project_document_lifecycle_timing,
|
||||
record_resource_performance_entry, run_resource_timing_buffer_full_task,
|
||||
};
|
||||
use self::range_live::{
|
||||
update_live_ranges_for_character_data_edit, update_live_ranges_for_character_data_reset,
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::*;
|
||||
use crate::util::{get_private_value, set_private_value};
|
||||
|
||||
mod entries;
|
||||
mod fetch_timing;
|
||||
pub(crate) use fetch_timing::FetchResourceTiming;
|
||||
mod install;
|
||||
mod lazy_subobjects;
|
||||
mod marks_measures;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::ResourcePerformanceEntry;
|
||||
use crate::types::AsyncSubresourceFetchResponseFilter;
|
||||
use moli_fetch::{FetchUrlList, RequestMode, ResponseCacheState, ResponseHead};
|
||||
use moli_url::WebOrigin;
|
||||
use url::Url;
|
||||
|
||||
/// Request-time metadata, independent of the callback or Realm that happens to
|
||||
/// be active when the body finishes. This must not retain any V8 objects.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FetchResourceTiming {
|
||||
start_unix_millis: f64,
|
||||
initiator_url: String,
|
||||
}
|
||||
|
||||
impl FetchResourceTiming {
|
||||
pub(crate) fn capture(scope: &mut v8::PinScope<'_, '_>, origin: &WebOrigin) -> Self {
|
||||
Self {
|
||||
start_unix_millis: crate::context_bootstrap::unix_epoch_millis(),
|
||||
initiator_url: crate::resource_initiator::current_url(scope, origin),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completed(
|
||||
&self,
|
||||
url: &Url,
|
||||
origin: &WebOrigin,
|
||||
mode: RequestMode,
|
||||
head: &ResponseHead,
|
||||
body_size: usize,
|
||||
filter: Option<AsyncSubresourceFetchResponseFilter>,
|
||||
) -> ResourcePerformanceEntry {
|
||||
let filter =
|
||||
filter.or_else(|| crate::network_host::network_response_filter(origin, head, mode));
|
||||
let readable = filter.is_none_or(|filter| filter.is_readable());
|
||||
let mut entry = ResourcePerformanceEntry::from_streaming_network_response(
|
||||
url.as_str(),
|
||||
"fetch",
|
||||
Some(self.start_unix_millis),
|
||||
head,
|
||||
body_size,
|
||||
);
|
||||
// CORS controls response metadata and script attribution. TAO controls
|
||||
// timing/body sizes separately, including for opaque responses.
|
||||
if readable && head.cache_state == ResponseCacheState::None {
|
||||
entry.initiator_url.clone_from(&self.initiator_url);
|
||||
}
|
||||
if !readable {
|
||||
entry.response_status = 0.0;
|
||||
entry.content_type.clear();
|
||||
}
|
||||
if !timing_allowed(origin, head) {
|
||||
entry.transfer_size = 0.0;
|
||||
entry.encoded_body_size = 0.0;
|
||||
entry.decoded_body_size = 0.0;
|
||||
}
|
||||
entry
|
||||
}
|
||||
|
||||
pub(crate) fn failed(&self, url: &Url) -> ResourcePerformanceEntry {
|
||||
ResourcePerformanceEntry::from_network_failure(
|
||||
url.as_str(),
|
||||
"fetch",
|
||||
Some(self.start_unix_millis),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_allowed(origin: &WebOrigin, head: &ResponseHead) -> bool {
|
||||
// Once a redirect has failed TAO, a later response cannot restore access.
|
||||
// Likewise, returning home does not reset cross-origin response tainting.
|
||||
let mut cross_origin = false;
|
||||
for (index, redirect) in head.redirect_chain.iter().enumerate() {
|
||||
cross_origin |= !origin.same_origin_url(&redirect.from_url);
|
||||
let urls = FetchUrlList::new(&redirect.from_url, &head.redirect_chain[..index]);
|
||||
if cross_origin && !allows_timing(&redirect.headers, &urls.serialized_origin(origin)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cross_origin |= !origin.same_origin_url(&head.final_url);
|
||||
!cross_origin || allows_timing(&head.headers, &head.url_list().serialized_origin(origin))
|
||||
}
|
||||
|
||||
fn allows_timing(headers: &[(String, Vec<u8>)], origin: &str) -> bool {
|
||||
headers
|
||||
.iter()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("timing-allow-origin"))
|
||||
.filter_map(|(_, value)| std::str::from_utf8(value).ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(|value| value.trim_matches(|ch: char| ch.is_ascii_whitespace()))
|
||||
.any(|value| value == "*" || value == origin)
|
||||
}
|
||||
@@ -601,6 +601,7 @@ impl JsContextHost {
|
||||
&mut self,
|
||||
fetch_context: super::WindowFetchContext,
|
||||
resolver: v8::Global<v8::PromiseResolver>,
|
||||
resource_timing: crate::context_bootstrap::FetchResourceTiming,
|
||||
keepalive: bool,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
@@ -641,6 +642,7 @@ impl JsContextHost {
|
||||
continuation: PendingSubresourceContinuation::Fetch(
|
||||
crate::types::PendingWindowFetchContinuation::new(
|
||||
resolver,
|
||||
resource_timing,
|
||||
keepalive,
|
||||
cache_mode,
|
||||
connect_policy,
|
||||
@@ -886,6 +888,7 @@ impl JsContextHost {
|
||||
&mut self,
|
||||
fetch_context: super::WindowFetchContext,
|
||||
resolver: v8::Global<v8::PromiseResolver>,
|
||||
resource_timing: crate::context_bootstrap::FetchResourceTiming,
|
||||
keepalive: bool,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
@@ -931,6 +934,7 @@ impl JsContextHost {
|
||||
continuation: PendingSubresourceContinuation::Fetch(
|
||||
crate::types::PendingWindowFetchContinuation::new(
|
||||
resolver,
|
||||
resource_timing,
|
||||
keepalive,
|
||||
cache_mode,
|
||||
connect_policy,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(super) fn record_intercepted_fetch(
|
||||
host.record_pending_subresource_fetch(
|
||||
prepared.fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.resource_timing,
|
||||
prepared.keepalive,
|
||||
prepared.connect_policy,
|
||||
prepared.csp_report_context,
|
||||
@@ -243,6 +244,7 @@ pub(super) fn spawn_network_fetch(
|
||||
let internal_id = host.record_async_subresource_fetch(
|
||||
prepared.fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.resource_timing,
|
||||
prepared.keepalive,
|
||||
prepared.connect_policy,
|
||||
prepared.csp_report_context,
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(super) struct PreparedWindowFetchRequest {
|
||||
pub(super) csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
pub(super) document_url: url::Url,
|
||||
pub(super) request_origin: moli_url::WebOrigin,
|
||||
pub(super) resource_timing: crate::context_bootstrap::FetchResourceTiming,
|
||||
pub(super) network_partition_key: Option<String>,
|
||||
pub(super) document_referrer_policy: Option<String>,
|
||||
pub(super) policy_context: crate::types::SubresourcePolicyContext,
|
||||
@@ -130,6 +131,10 @@ pub(super) fn prepare_window_fetch_request<'s>(
|
||||
let resolved_url = resolve_context_url(&base_url, &parsed.url, None)?;
|
||||
|
||||
Ok(PreparedWindowFetchRequest {
|
||||
resource_timing: crate::context_bootstrap::FetchResourceTiming::capture(
|
||||
scope,
|
||||
&request_origin,
|
||||
),
|
||||
frame_id,
|
||||
fetch_context,
|
||||
resource_loader,
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(super) fn dispatch_service_worker_fetch(
|
||||
let internal_id = host.record_async_subresource_fetch(
|
||||
prepared.fetch_context.duplicate(scope),
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.resource_timing.clone(),
|
||||
prepared.keepalive,
|
||||
prepared.connect_policy.clone(),
|
||||
prepared.csp_report_context.clone(),
|
||||
|
||||
@@ -657,6 +657,21 @@ impl ScriptVm {
|
||||
trace_fields,
|
||||
record_started,
|
||||
);
|
||||
if let PendingSubresourceContinuation::Fetch(fetch) = &pending.continuation {
|
||||
crate::context_bootstrap::record_resource_performance_entry(
|
||||
scope,
|
||||
fetch.resource_timing().completed(
|
||||
&pending.info.url,
|
||||
&pending.request_origin,
|
||||
pending.request_mode,
|
||||
&response.head(),
|
||||
response.body_bytes().len(),
|
||||
opaque_response_blocked
|
||||
.then_some(AsyncSubresourceFetchResponseFilter::Opaque)
|
||||
.or(response_filter),
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut observable_response = response;
|
||||
if matches!(
|
||||
pending.info.resource_type,
|
||||
@@ -878,6 +893,10 @@ impl ScriptVm {
|
||||
moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
|
||||
match pending.continuation {
|
||||
PendingSubresourceContinuation::Fetch(fetch) => {
|
||||
crate::context_bootstrap::record_resource_performance_entry(
|
||||
scope,
|
||||
fetch.resource_timing().failed(&pending.info.url),
|
||||
);
|
||||
let resolver = fetch
|
||||
.into_resolver()
|
||||
.expect("detached keepalive failure is handled before V8 entry");
|
||||
|
||||
@@ -727,6 +727,10 @@ impl ScriptVm {
|
||||
.record_subresource_network(network_record);
|
||||
match &pending.continuation {
|
||||
PendingSubresourceContinuation::Fetch(fetch) => {
|
||||
crate::context_bootstrap::record_resource_performance_entry(
|
||||
scope,
|
||||
fetch.resource_timing().failed(&pending.info.url),
|
||||
);
|
||||
let resolver = fetch
|
||||
.resolver()
|
||||
.expect("detached keepalive stream is handled before V8 entry");
|
||||
@@ -1799,6 +1803,19 @@ impl ScriptVm {
|
||||
trace_fields,
|
||||
record_started,
|
||||
);
|
||||
if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation {
|
||||
crate::context_bootstrap::record_resource_performance_entry(
|
||||
scope,
|
||||
fetch.resource_timing().completed(
|
||||
&streaming.pending.info.url,
|
||||
&streaming.pending.request_origin,
|
||||
streaming.pending.request_mode,
|
||||
&streaming.head,
|
||||
response_body_size,
|
||||
streaming.response_filter,
|
||||
),
|
||||
);
|
||||
}
|
||||
if let PendingSubresourceContinuation::Xhr { xhr, initiator_url, .. } =
|
||||
streaming.pending.continuation
|
||||
&& let Some(response_body) = xhr_delivery_body
|
||||
@@ -1851,6 +1868,12 @@ impl ScriptVm {
|
||||
}
|
||||
}
|
||||
Err(error_text) => {
|
||||
if let PendingSubresourceContinuation::Fetch(fetch) = &streaming.pending.continuation {
|
||||
crate::context_bootstrap::record_resource_performance_entry(
|
||||
scope,
|
||||
fetch.resource_timing().failed(&streaming.pending.info.url),
|
||||
);
|
||||
}
|
||||
let error_started =
|
||||
moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
|
||||
crate::network_host::error_pending_network_body_stream(
|
||||
|
||||
@@ -157,6 +157,10 @@ fn pending_fetch_continuation<'s>(
|
||||
crate::types::PendingSubresourceContinuation::Fetch(
|
||||
crate::types::PendingWindowFetchContinuation::new(
|
||||
v8::Global::new(scope, resolver),
|
||||
crate::context_bootstrap::FetchResourceTiming::capture(
|
||||
scope,
|
||||
&host.document_url().into(),
|
||||
),
|
||||
false,
|
||||
moli_fetch::RequestCacheMode::Default,
|
||||
crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(
|
||||
|
||||
@@ -327,6 +327,7 @@ fn register_pending_window_fetch_for_test(
|
||||
let internal_id = host.record_async_subresource_fetch(
|
||||
fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&url).into()),
|
||||
keepalive,
|
||||
connect_policy,
|
||||
csp_report_context,
|
||||
@@ -500,6 +501,7 @@ fn register_pending_window_fetch_with_connect_policy_for_test(
|
||||
let internal_id = host.record_async_subresource_fetch(
|
||||
fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
crate::context_bootstrap::FetchResourceTiming::capture(scope, &(&document_url).into()),
|
||||
keepalive,
|
||||
crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy),
|
||||
csp_report_context,
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::script_provenance::CompiledStringProvenance;
|
||||
|
||||
mod event_handler_getter;
|
||||
mod fetch;
|
||||
mod geolocation_watch;
|
||||
mod realm_lifetime;
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
use super::*;
|
||||
|
||||
fn finish_fetch(
|
||||
vm: &mut ScriptVm,
|
||||
request: &crate::types::PendingSubresourceFetchInfo,
|
||||
response: crate::protocol_types::NavigationResponse,
|
||||
streaming: bool,
|
||||
) {
|
||||
if streaming {
|
||||
let body_source_id = crate::network_host::new_network_body_source_id();
|
||||
vm.start_streaming_async_subresource_fetch(
|
||||
crate::types::AsyncSubresourceStreamingStarted {
|
||||
internal_id: request.internal_id,
|
||||
request_url: request.url.clone(),
|
||||
request_method: request.method.clone(),
|
||||
request_headers: request.request_headers.clone(),
|
||||
request_body: None,
|
||||
skip_fetch_security_validation: false,
|
||||
response_filter: None,
|
||||
body_source_id,
|
||||
head: response.head(),
|
||||
network_request_headers: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vm.eval("String(performance.getEntriesByType('resource').length)")
|
||||
.unwrap(),
|
||||
"0",
|
||||
"headers must not publish an unfinished Fetch"
|
||||
);
|
||||
vm.append_streaming_async_subresource_fetch_chunk(
|
||||
body_source_id,
|
||||
response.body_bytes().to_vec(),
|
||||
);
|
||||
assert_eq!(
|
||||
vm.eval("String(performance.getEntriesByType('resource').length)")
|
||||
.unwrap(),
|
||||
"0",
|
||||
"body chunks must not publish an unfinished Fetch"
|
||||
);
|
||||
vm.finish_streaming_async_subresource_fetch(request.internal_id, body_source_id, Ok(()))
|
||||
.unwrap();
|
||||
} else {
|
||||
vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion {
|
||||
internal_id: request.internal_id,
|
||||
request_url: request.url.clone(),
|
||||
request_method: request.method.clone(),
|
||||
request_headers: request.request_headers.clone(),
|
||||
request_body: None,
|
||||
response_status_text: None,
|
||||
skip_fetch_security_validation: false,
|
||||
response_filter: None,
|
||||
network_error_text: None,
|
||||
result: Ok(response).into(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_resource_timing_publishes_once_after_buffered_and_streaming_body_completion() {
|
||||
let document_url = Url::parse("https://fetch-timing.test/page").unwrap();
|
||||
let caller = document_url.join("caller.js").unwrap();
|
||||
for streaming in [false, true] {
|
||||
let mut vm = new_storage_test_vm(document_url.as_str());
|
||||
vm.set_fetch_subresource_interception(
|
||||
true,
|
||||
Some(crate::types::SubresourceResourceType::Fetch),
|
||||
);
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
r#"
|
||||
globalThis.observed = [];
|
||||
globalThis.observer = new PerformanceObserver(list => observed.push(...list.getEntries()));
|
||||
observer.observe({type:'resource'});
|
||||
globalThis.fetchResponse = null;
|
||||
globalThis.started = performance.now();
|
||||
fetch('/asset').then(response => fetchResponse = response);
|
||||
"#,
|
||||
&caller,
|
||||
&document_url,
|
||||
);
|
||||
let requests = vm.take_pending_subresource_fetch_infos();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
finish_fetch(
|
||||
&mut vm,
|
||||
request,
|
||||
crate::protocol_types::NavigationResponse::from_text_body(
|
||||
request.url.clone(),
|
||||
200,
|
||||
vec![("Content-Type".to_owned(), b"text/plain".to_vec())],
|
||||
"body".to_owned(),
|
||||
),
|
||||
streaming,
|
||||
);
|
||||
assert_eq!(vm.eval("String(fetchResponse.status)").unwrap(), "200");
|
||||
assert_eq!(vm.eval(r#"JSON.stringify({
|
||||
entries:performance.getEntriesByType('resource').map(entry => ({
|
||||
name:entry.name, type:entry.initiatorType, source:entry.initiatorUrl,
|
||||
jsonSource:entry.toJSON().initiatorUrl, status:entry.responseStatus,
|
||||
encoded:entry.encodedBodySize, decoded:entry.decodedBodySize,
|
||||
transfer:entry.transferSize, native:entry instanceof PerformanceResourceTiming,
|
||||
start:entry.startTime >= started && entry.startTime <= performance.now()
|
||||
})),
|
||||
observer:[...observed, ...observer.takeRecords()].map(entry => entry.name)
|
||||
})"#).unwrap(), serde_json::json!({
|
||||
"entries":[{"name":request.url.as_str(),"type":"fetch","source":caller.as_str(),
|
||||
"jsonSource":caller.as_str(),"status":200,"encoded":4,"decoded":4,"transfer":304,"native":true,"start":true}],
|
||||
"observer":[request.url.as_str()]
|
||||
}).to_string(), "streaming={streaming}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_resource_timing_retains_initiators_through_real_network_and_lazy_performance() {
|
||||
let server = StaticHttpServer::spawn_with_responder(
|
||||
3,
|
||||
"Content-Type: text/plain\r\nCache-Control: no-store\r\n",
|
||||
|_, _| "ok".to_owned(),
|
||||
)
|
||||
.await;
|
||||
let base = server.base_url();
|
||||
let loader = static_http_loader(std::iter::empty::<String>());
|
||||
let mut vm = new_storage_page_task_executor_test_vm_with_loader(base.as_str(), &loader);
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
r#"
|
||||
globalThis.fetchDone = 0;
|
||||
globalThis.get = path => fetch(path).then(response => response.text()).then(() => fetchDone++);
|
||||
"#,
|
||||
&base.join("library.js").unwrap(),
|
||||
&base,
|
||||
);
|
||||
let caller = base.join("caller.js?version=2").unwrap();
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
r#"
|
||||
get('/direct');
|
||||
Promise.resolve().then(() => get('/promise'));
|
||||
setTimeout(() => get('/timer'), 0);
|
||||
"#,
|
||||
&caller,
|
||||
&base,
|
||||
);
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
"globalThis.laterScript = true;",
|
||||
&base.join("later.js").unwrap(),
|
||||
&base,
|
||||
);
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(fetchDone)",
|
||||
"3",
|
||||
"real Fetch body completion",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(vm.eval(r#"JSON.stringify(performance.getEntriesByType('resource')
|
||||
.map(entry => [new URL(entry.name).pathname, entry.initiatorType, entry.initiatorUrl, entry.toJSON().initiatorUrl]).sort())"#).unwrap(),
|
||||
serde_json::json!([
|
||||
["/direct","fetch",caller.as_str(),caller.as_str()],
|
||||
["/promise","fetch",caller.as_str(),caller.as_str()],
|
||||
["/timer","fetch",caller.as_str(),caller.as_str()]
|
||||
]).to_string());
|
||||
assert_eq!(server.finish().await.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_resource_timing_reports_failed_network_requests() {
|
||||
let base = Url::parse("https://failed-fetch-timing.test/page").unwrap();
|
||||
let mut vm = new_storage_test_vm(base.as_str());
|
||||
vm.set_fetch_subresource_interception(true, Some(crate::types::SubresourceResourceType::Fetch));
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
"fetch('/failed').catch(error => globalThis.fetchError = error.name);",
|
||||
&base,
|
||||
&base,
|
||||
);
|
||||
let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
|
||||
vm.complete_async_subresource_fetch(crate::types::AsyncSubresourceFetchCompletion {
|
||||
internal_id: request.internal_id,
|
||||
request_url: request.url.clone(),
|
||||
request_method: request.method,
|
||||
request_headers: request.request_headers,
|
||||
request_body: None,
|
||||
response_status_text: None,
|
||||
skip_fetch_security_validation: false,
|
||||
response_filter: None,
|
||||
network_error_text: None,
|
||||
result: Err("network failed".to_owned()).into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(vm.eval("fetchError").unwrap(), "TypeError");
|
||||
assert_eq!(
|
||||
vm.eval(
|
||||
r#"JSON.stringify(performance.getEntriesByType('resource')
|
||||
.map(entry => [entry.name,entry.initiatorType,entry.responseStatus,entry.transferSize]))"#
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([[request.url.as_str(), "fetch", 0, 0]]).to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_resource_timing_separates_cors_attribution_from_timing_allow_origin() {
|
||||
let document = Url::parse("https://fetch-timing.test/page").unwrap();
|
||||
let caller = document.join("caller.js").unwrap();
|
||||
let streaming_modes: &[bool] = &[false, true];
|
||||
// The production transport buffers no-cors responses for ORB body sniffing.
|
||||
let buffered: &[bool] = &[false];
|
||||
for (modes, mode, tao, expected_source, expected_size, expected_status) in [
|
||||
(streaming_modes, "cors", None, caller.as_str(), 0, 200),
|
||||
(streaming_modes, "cors", Some("*"), caller.as_str(), 4, 200),
|
||||
(
|
||||
streaming_modes,
|
||||
"cors",
|
||||
Some("https://fetch-timing.test"),
|
||||
caller.as_str(),
|
||||
4,
|
||||
200,
|
||||
),
|
||||
(
|
||||
streaming_modes,
|
||||
"cors",
|
||||
Some("https://other.test"),
|
||||
caller.as_str(),
|
||||
0,
|
||||
200,
|
||||
),
|
||||
(buffered, "no-cors", None, "", 0, 0),
|
||||
(buffered, "no-cors", Some("*"), "", 4, 0),
|
||||
] {
|
||||
for &streaming in modes {
|
||||
let mut vm = new_storage_test_vm(document.as_str());
|
||||
vm.set_fetch_subresource_interception(
|
||||
true,
|
||||
Some(crate::types::SubresourceResourceType::Fetch),
|
||||
);
|
||||
execute_with_source(
|
||||
&mut vm,
|
||||
&format!(
|
||||
"fetch('https://cross-origin.test/asset', {{mode:'{mode}'}}).then(response => globalThis.fetchResponse = response);"
|
||||
),
|
||||
&caller,
|
||||
&document,
|
||||
);
|
||||
let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
|
||||
let mut headers = vec![
|
||||
("Content-Type".to_owned(), b"text/plain".to_vec()),
|
||||
("Access-Control-Allow-Origin".to_owned(), b"*".to_vec()),
|
||||
];
|
||||
if let Some(tao) = tao {
|
||||
headers.push(("Timing-Allow-Origin".to_owned(), tao.as_bytes().to_vec()));
|
||||
}
|
||||
finish_fetch(
|
||||
&mut vm,
|
||||
&request,
|
||||
crate::protocol_types::NavigationResponse::from_text_body(
|
||||
request.url.clone(),
|
||||
200,
|
||||
headers,
|
||||
"body".to_owned(),
|
||||
),
|
||||
streaming,
|
||||
);
|
||||
assert_eq!(
|
||||
vm.eval(
|
||||
r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [
|
||||
entry.name, entry.initiatorType, entry.initiatorUrl, entry.responseStatus,
|
||||
entry.encodedBodySize, entry.decodedBodySize, entry.transferSize, entry.contentType
|
||||
]))"#
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([[
|
||||
request.url.as_str(),
|
||||
"fetch",
|
||||
expected_source,
|
||||
expected_status,
|
||||
expected_size,
|
||||
expected_size,
|
||||
if expected_size == 0 { 0 } else { 304 },
|
||||
if mode == "cors" { "text/plain" } else { "" }
|
||||
]])
|
||||
.to_string(),
|
||||
"streaming={streaming}, mode={mode}, tao={tao:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_resource_timing_keeps_cache_hits_and_unknown_script_sources_unattributed() {
|
||||
let document = Url::parse("https://fetch-cache-timing.test/page").unwrap();
|
||||
let caller = document.join("caller.js").unwrap();
|
||||
for streaming in [false, true] {
|
||||
for (cache, transfer) in [
|
||||
(moli_fetch::ResponseCacheState::None, 304),
|
||||
(moli_fetch::ResponseCacheState::Local, 0),
|
||||
(moli_fetch::ResponseCacheState::Validated, 300),
|
||||
] {
|
||||
for known_source in [false, true] {
|
||||
let mut vm = new_storage_test_vm(document.as_str());
|
||||
vm.set_fetch_subresource_interception(
|
||||
true,
|
||||
Some(crate::types::SubresourceResourceType::Fetch),
|
||||
);
|
||||
let script =
|
||||
"fetch('/asset').then(response => globalThis.fetchResponse = response);";
|
||||
if known_source {
|
||||
execute_with_source(&mut vm, script, &caller, &document);
|
||||
} else {
|
||||
vm.exec(script, None).unwrap();
|
||||
}
|
||||
let request = vm.take_pending_subresource_fetch_infos().pop().unwrap();
|
||||
let mut response = crate::protocol_types::NavigationResponse::from_text_body(
|
||||
request.url.clone(),
|
||||
200,
|
||||
Vec::new(),
|
||||
"body".to_owned(),
|
||||
);
|
||||
response.cache_state = cache;
|
||||
response.from_cache = cache != moli_fetch::ResponseCacheState::None;
|
||||
finish_fetch(&mut vm, &request, response, streaming);
|
||||
let source = if known_source && cache == moli_fetch::ResponseCacheState::None {
|
||||
caller.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
assert_eq!(
|
||||
vm.eval(
|
||||
r#"JSON.stringify(performance.getEntriesByType('resource').map(entry => [
|
||||
entry.initiatorUrl, entry.encodedBodySize, entry.transferSize
|
||||
]))"#
|
||||
)
|
||||
.unwrap(),
|
||||
serde_json::json!([[source, 4, transfer]]).to_string(),
|
||||
"streaming={streaming}, cache={cache:?}, known_source={known_source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,7 @@ impl PendingSubresourceContinuation {
|
||||
|
||||
pub(super) struct PendingWindowFetchContinuation {
|
||||
promise: PendingWindowFetchPromise,
|
||||
resource_timing: crate::context_bootstrap::FetchResourceTiming,
|
||||
keepalive: bool,
|
||||
cache_mode: moli_fetch::RequestCacheMode,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
@@ -260,6 +261,7 @@ enum PendingWindowFetchPromise {
|
||||
impl PendingWindowFetchContinuation {
|
||||
pub(super) fn new(
|
||||
resolver: v8::Global<v8::PromiseResolver>,
|
||||
resource_timing: crate::context_bootstrap::FetchResourceTiming,
|
||||
keepalive: bool,
|
||||
cache_mode: moli_fetch::RequestCacheMode,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
@@ -267,6 +269,7 @@ impl PendingWindowFetchContinuation {
|
||||
) -> Self {
|
||||
Self {
|
||||
promise: PendingWindowFetchPromise::Active(resolver),
|
||||
resource_timing,
|
||||
keepalive,
|
||||
cache_mode,
|
||||
connect_policy,
|
||||
@@ -278,6 +281,10 @@ impl PendingWindowFetchContinuation {
|
||||
self.cache_mode
|
||||
}
|
||||
|
||||
pub(super) fn resource_timing(&self) -> &crate::context_bootstrap::FetchResourceTiming {
|
||||
&self.resource_timing
|
||||
}
|
||||
|
||||
pub(super) fn keepalive(&self) -> bool {
|
||||
self.keepalive
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user