From 417946cc5fdc2a193ecdb4e156c6a76e5245909d Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 11 Oct 2026 23:25:52 +0800 Subject: [PATCH] fix(iframe): centralize document load delivery --- .../auxiliary_page_identity.rs | 22 +++- .../mutation_commands/tree/detached.rs | 49 +------ .../native_bridge/context_host/host_loads.rs | 4 + .../document/detached_objects/mutation.rs | 19 --- .../src/native_bridge/node/mutation/core.rs | 56 +------- .../tests/dom_xhr/dom/elements_and_events.rs | 10 +- .../script_vm/tests/iframe_load_delivery.rs | 109 +++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../iframe-load-delivery.chromium.json | 26 ++++ .../tests/fixtures/iframe-load-delivery.js | 124 ++++++++++++++++++ scripts/iframe-load-chromium-differential.py | 100 ++++++++++++++ 11 files changed, 391 insertions(+), 129 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/iframe_load_delivery.rs create mode 100644 moli-renderer-v8/tests/fixtures/iframe-load-delivery.chromium.json create mode 100644 moli-renderer-v8/tests/fixtures/iframe-load-delivery.js create mode 100644 scripts/iframe-load-chromium-differential.py diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page/auxiliary_page_identity.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page/auxiliary_page_identity.rs index b77ff5d50b..2deb4959ab 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page/auxiliary_page_identity.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page/auxiliary_page_identity.rs @@ -1230,13 +1230,27 @@ async fn auxiliary_adopted_iframe_loads_once_after_native_connection() { navigate_dynamic_page_and_wait_for_load(&mut child, 2, &format!("http://{fixture_addr}/popup")) .await; evaluate_window_name_probe(&mut opener, 3, r#"window.adoptedIframeLoaded=false;window.adoptedLoadCount=0; + window.adoptedLoadEvents=[]; window.adoptedFrame=document.createElement('iframe'); - adoptedFrame.onload=function(){adoptedLoadCount++;adoptedIframeLoaded=this===adoptedFrame&&this.ownerDocument===p.document}; - p.document.body.append(adoptedFrame);true"#).await; + adoptedFrame.onload=function(){adoptedLoadCount++;adoptedLoadEvents.push('load');adoptedIframeLoaded=this===adoptedFrame&&this.ownerDocument===p.document}; + p.document.body.append(adoptedFrame); + adoptedLoadEvents.push('after-insert'); + true"#).await; wait_for_value(&mut opener, "adoptedIframeLoaded", json!(true)).await; + // Cross two tasks on the destination Page before checking for duplicate + // load delivery. Install the checkpoints in that Page's own evaluation. + evaluate_window_name_probe( + &mut child, + 3, + r#"window.adoptedLoadCheckpoint=false; + setTimeout(()=>setTimeout(()=>{opener.adoptedLoadEvents.push('checkpoint');adoptedLoadCheckpoint=true},0),0); + true"#, + ) + .await; + wait_for_value(&mut child, "adoptedLoadCheckpoint", json!(true)).await; assert_eq!( - evaluate_window_name_probe(&mut opener, 4, "adoptedLoadCount").await, - json!(1) + evaluate_window_name_probe(&mut opener, 4, "[adoptedLoadCount,adoptedLoadEvents]").await, + json!([1, ["load", "after-insert", "checkpoint"]]) ); abort_test_cdp_server(server).await; } diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs index f94323bd2c..d57dc69c06 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands/tree/detached.rs @@ -1,9 +1,8 @@ use super::policy::{TreeMutationSourceProfile, TreeNoncePolicy, TreeReactionDispatchPolicy}; use crate::{ - document_runtime::{DocumentRuntime, DomHandle, EventTargetHandle}, + document_runtime::{DocumentRuntime, DomHandle}, mutation_coordinator::ConnectedScriptMutationPolicy, native_bridge::JsContextHost, - util::v8str, }; impl DocumentRuntime { @@ -52,10 +51,7 @@ impl DocumentRuntime { reference_child: Option, reaction_policy: TreeReactionDispatchPolicy, ) -> bool { - let insertion_roots = self - .fragment_insertion_children(child) - .unwrap_or_else(|| vec![child]); - let changed = self.insert_before_with_nonce_handling( + self.insert_before_with_nonce_handling( scope, host_ptr, parent, @@ -68,15 +64,7 @@ impl DocumentRuntime { reaction_policy, TreeNoncePolicy::HideInsertedContentAttributes, ), - ); - if changed { - self.dispatch_detached_native_iframe_load_after_insert( - scope, - host_ptr, - &insertion_roots, - ); - } - changed + ) } pub(crate) fn remove_detached_native_child( @@ -104,35 +92,4 @@ impl DocumentRuntime { TreeMutationSourceProfile::js_dom_api_appending_to_current_reaction_queue(), ) } - - fn dispatch_detached_native_iframe_load_after_insert( - &mut self, - scope: &mut v8::PinScope<'_, '_>, - host_ptr: *mut JsContextHost, - roots: &[DomHandle], - ) { - let Some(event_ctor) = scope - .get_current_context() - .global(scope) - .get(scope, v8str(scope, "Event").into()) - .and_then(|value| v8::Local::::try_from(value).ok()) - else { - return; - }; - for &root in roots { - if !self.dom_host.is_html_element_named(root, "iframe") { - continue; - } - let Some(event) = event_ctor.new_instance(scope, &[v8str(scope, "load").into()]) else { - continue; - }; - let _ = self.dispatch_public_event_best_effort( - scope, - host_ptr, - EventTargetHandle::Node(root), - event, - "detached native iframe load after insert", - ); - } - } } diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs b/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs index b87186f952..283a349531 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs @@ -316,6 +316,10 @@ impl JsContextHost { ); } + /// Delivers child Document loads exclusively through FrameOwnerStore's + /// owner and phase admissions, including synchronous initial about:blank. + /// DOM insertion only connects the frame; it must not synthesize load on + /// native nodes or their paired foreign reflectors. fn run_child_browsing_context_load_delivery( &mut self, scope: &mut v8::PinScope<'_, '_>, diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs index 7710f74abd..19a5f2519a 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/mutation.rs @@ -897,11 +897,6 @@ fn detached_insert_node_with_current_queue_policy<'s>( detached_replace_children_array(scope, parent, prospective_array); } detached_record_tree_mutation(scope, parent); - if !parent_has_native_handle { - for node in insert_nodes { - dispatch_detached_iframe_load_after_insert(scope, node); - } - } Ok(child) } @@ -936,17 +931,3 @@ fn detached_pre_insert_contains<'s>( } false } - -fn dispatch_detached_iframe_load_after_insert<'s>( - scope: &mut v8::PinScope<'s, '_>, - node: v8::Local<'s, v8::Object>, -) { - if detached_node_type(scope, node) == Some(1) - && detached_element_local_name(scope, node) - .is_some_and(|name| name.eq_ignore_ascii_case("iframe")) - { - let _ = crate::detached_event_target::dispatch_detached_simple_event( - scope, node, "load", false, false, false, - ); - } -} diff --git a/moli-renderer-v8/src/native_bridge/node/mutation/core.rs b/moli-renderer-v8/src/native_bridge/node/mutation/core.rs index a6cec8c62c..304becc717 100644 --- a/moli-renderer-v8/src/native_bridge/node/mutation/core.rs +++ b/moli-renderer-v8/src/native_bridge/node/mutation/core.rs @@ -1,10 +1,7 @@ use super::fragment::validate_document_sequence; use super::*; use crate::custom_elements; -use crate::native_bridge::document::{ - detached_native_handle_for_runtime, is_attr_node_value, - paired_detached_native_object_for_handle, -}; +use crate::native_bridge::document::{detached_native_handle_for_runtime, is_attr_node_value}; fn required_tree_insertion_node_arg_handle( scope: &mut v8::PinScope<'_, '_>, @@ -43,48 +40,6 @@ fn set_original_node_arg_or_wrapped_handle<'s>( set_wrapped_node_or_null(scope, rv, runtime_ptr, Some(handle)); } -fn inserted_handles_for_post_insert_events( - runtime: &JsContextHost, - handle: DomHandle, -) -> Vec { - if runtime - .dom_host() - .node(handle) - .is_some_and(Node::is_document_fragment) - { - runtime.dom_host().child_handles(handle).collect() - } else { - vec![handle] - } -} - -fn dispatch_detached_post_insert_events( - scope: &mut v8::PinScope<'_, '_>, - runtime_ptr: *mut JsContextHost, - handles: &[DomHandle], -) { - for handle in handles { - // A connected native iframe already owns its initial load delivery. - // A paired foreign reflector must not add a second synthetic event. - if unsafe { &*runtime_ptr }.child_browsing_context_host_is_active(*handle) { - continue; - } - if !unsafe { &*runtime_ptr } - .dom_host() - .is_html_element_named(*handle, "iframe") - { - continue; - } - let Some(target) = paired_detached_native_object_for_handle(scope, runtime_ptr, *handle) - else { - continue; - }; - let _ = crate::detached_event_target::dispatch_detached_simple_event( - scope, target, "load", false, false, false, - ); - } -} - pub(in crate::native_bridge) fn node_append_child_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -109,8 +64,6 @@ pub(in crate::native_bridge) fn node_append_child_callback<'s>( return; } let child_value = args.get(0); - let post_insert_event_handles = - inserted_handles_for_post_insert_events(unsafe { &*runtime_ptr }, child); let inserted = custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { unsafe { &mut *runtime_ptr }.append_child_appending_to_current_reaction_queue( @@ -124,7 +77,6 @@ pub(in crate::native_bridge) fn node_append_child_callback<'s>( throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return; } - dispatch_detached_post_insert_events(scope, runtime_ptr, &post_insert_event_handles); set_original_node_arg_or_wrapped_handle(scope, &mut rv, runtime_ptr, child_value, child); } @@ -210,8 +162,6 @@ pub(in crate::native_bridge) fn node_insert_before_callback<'s>( return; } let child_value = args.get(0); - let post_insert_event_handles = - inserted_handles_for_post_insert_events(unsafe { &*runtime_ptr }, child); let inserted = custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { unsafe { &mut *runtime_ptr }.insert_before_appending_to_current_reaction_queue( @@ -226,7 +176,6 @@ pub(in crate::native_bridge) fn node_insert_before_callback<'s>( throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return; } - dispatch_detached_post_insert_events(scope, runtime_ptr, &post_insert_event_handles); set_original_node_arg_or_wrapped_handle(scope, &mut rv, runtime_ptr, child_value, child); } @@ -658,8 +607,6 @@ pub(in crate::native_bridge) fn node_replace_child_callback<'s>( ) { return; } - let post_insert_event_handles = - inserted_handles_for_post_insert_events(unsafe { &*runtime_ptr }, new_child); let replaced = custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { unsafe { &mut *runtime_ptr }.replace_child_appending_to_current_reaction_queue( @@ -674,6 +621,5 @@ pub(in crate::native_bridge) fn node_replace_child_callback<'s>( throw_dom_exception(scope, "HierarchyRequestError", 3, "Hierarchy Error"); return; } - dispatch_detached_post_insert_events(scope, runtime_ptr, &post_insert_event_handles); set_original_node_arg_or_wrapped_handle(scope, &mut rv, runtime_ptr, args.get(1), old_child); } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/elements_and_events.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/elements_and_events.rs index f119742c1c..b7406b5171 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/elements_and_events.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/elements_and_events.rs @@ -216,7 +216,7 @@ fn dom_parser_detached_elements_dispatch_local_events() { } #[test] -fn dom_parser_detached_iframe_load_ignores_tampered_non_elements() { +fn dom_parser_detached_insertion_does_not_dispatch_iframe_load() { let mut vm = new_storage_test_vm("https://dom-parser-detached-iframe-load-guard.test/"); let result = vm @@ -238,9 +238,9 @@ fn dom_parser_detached_iframe_load_ignores_tampered_non_elements() { })() "#, ) - .expect("DOMParser detached iframe load should guard node type"); + .expect("a Document without a browsing context should not load inserted iframes"); - assert_eq!(result, "0|1"); + assert_eq!(result, "0|0"); } #[test] @@ -292,7 +292,7 @@ fn dom_parser_detached_iframe_window_declares_own_methods() { } #[test] -fn dom_parser_detached_iframe_adopted_node_loads_after_insert() { +fn dom_parser_detached_iframe_adoption_does_not_dispatch_load() { let mut vm = new_storage_test_vm("https://dom-parser-detached-iframe-import-sync.test/"); let result = vm @@ -326,7 +326,7 @@ fn dom_parser_detached_iframe_adopted_node_loads_after_insert() { ) .expect("DOMParser detached iframe import source sync should evaluate"); - assert_eq!(result, "true|1|imported|true|true"); + assert_eq!(result, "true|0||true|true"); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/iframe_load_delivery.rs b/moli-renderer-v8/src/script_vm/tests/iframe_load_delivery.rs new file mode 100644 index 0000000000..98526c45da --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/iframe_load_delivery.rs @@ -0,0 +1,109 @@ +use super::*; + +const FIXTURE: &str = include_str!("../../../tests/fixtures/iframe-load-delivery.js"); +const CHROMIUM: &str = include_str!("../../../tests/fixtures/iframe-load-delivery.chromium.json"); + +fn snapshot(vm: &mut ScriptVm) -> serde_json::Value { + serde_json::from_str( + &vm.eval("JSON.stringify(iframeLoadDelivery.snapshot())") + .expect("iframe load trace should evaluate"), + ) + .expect("iframe load trace should be JSON") +} + +#[tokio::test] +async fn iframe_load_delivery_matches_chromium_before_and_after_owner_tasks() { + let oracle: serde_json::Value = serde_json::from_str(CHROMIUM).unwrap(); + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut mismatches = Vec::new(); + for (name, expected) in oracle["cases"].as_object().unwrap() { + let mut vm = new_storage_test_vm_with_loader("https://iframe-load-delivery.test/", &loader); + vm.eval(FIXTURE) + .expect("shared load fixture should install"); + vm.eval(&format!( + "iframeLoadDelivery.start({})", + serde_json::to_string(name).unwrap() + )) + .unwrap_or_else(|error| panic!("{name}: setup failed: {error}")); + let immediate = snapshot(&mut vm); + if immediate != expected["immediate"] { + mismatches.push(format!( + "{name} immediate: expected {}, got {immediate}", + expected["immediate"] + )); + } + // Exercise real owner sources through quiescence, including any + // navigation created by the synchronous initial load callback. + vm.drain_pending_child_frame_work_for_test_on_owner_sources() + .await; + let settled = snapshot(&mut vm); + if settled != expected["settled"] { + mismatches.push(format!( + "{name} settled: expected {}, got {settled}", + expected["settled"] + )); + } + assert!( + vm.run_next_child_frame_semantic_turn_for_test() + .await + .is_none(), + "{name}: load delivery must not leave a duplicate owner task" + ); + if matches!(name.as_str(), "pending-replacement" | "navigate-in-onload") { + assert_eq!( + vm.eval("iframeLoadDelivery.bodyText()").unwrap(), + "replacement", + "{name}: only the replacement Document should finish" + ); + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +#[tokio::test] +async fn iframe_load_delivery_discards_queued_load_for_replaced_document() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_test_vm_with_loader("https://iframe-load-replacement.test/", &loader); + vm.eval(FIXTURE).unwrap(); + vm.eval("iframeLoadDelivery.start('srcdoc')").unwrap(); + run_realm_prerequisite_then_expected_child_frame_semantic_turn_for_test( + &mut vm, + ChildFrameSemanticTurnKind::NavigationCommit, + "original Document should commit", + ) + .await; + for transition in ["interactive", "DOMContentLoaded", "complete"] { + run_realm_prerequisite_then_expected_child_frame_semantic_turn_for_test( + &mut vm, + ChildFrameSemanticTurnKind::DocumentLifecycle, + transition, + ) + .await; + } + // Complete has reserved HostLoad, but it has not called author JS yet. + vm.eval("iframeLoadDelivery.replace()").unwrap(); + assert_eq!( + vm.run_child_host_load_body_for_test().unwrap(), + Some(crate::script_vm::ChildHostLoadRunOutcome::ConsumedWithoutCallback), + "the superseded HostLoad must consume its reservation without calling author JS" + ); + assert_eq!(snapshot(&mut vm), serde_json::json!(["before", "after"])); + assert_eq!(vm.completed_child_frame_navigation_load_count(), 0); + + vm.drain_pending_child_frame_work_for_test_on_owner_sources() + .await; + assert_eq!( + snapshot(&mut vm), + serde_json::json!(["before", "after", "frame:handler", "frame:listener"]) + ); + assert_eq!( + vm.eval("iframeLoadDelivery.bodyText()").unwrap(), + "replacement" + ); + assert_eq!(vm.completed_child_frame_navigation_load_count(), 1); + assert!( + vm.run_next_child_frame_semantic_turn_for_test() + .await + .is_none() + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 88bb2353b7..6ef2bcd448 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2162,6 +2162,7 @@ mod event_receivers; mod geometry_point_conversion_order; mod headers_list; mod http_fixture; +mod iframe_load_delivery; mod media_owner_playback_interfaces; mod media_recorder_shell; mod performance_observer_contract; diff --git a/moli-renderer-v8/tests/fixtures/iframe-load-delivery.chromium.json b/moli-renderer-v8/tests/fixtures/iframe-load-delivery.chromium.json new file mode 100644 index 0000000000..3ef80b5c27 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/iframe-load-delivery.chromium.json @@ -0,0 +1,26 @@ +{ + "product": "Chrome/145.0.7632.116", + "revision": "@7d28075c6a9ba147e6df449209001258bb82a122", + "cases": { + "inert-parser-append": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-parser-insert": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-parser-replace": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-html-append": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-html-insert": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-html-replace": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "inert-adoption": {"immediate": ["before", "after"], "settled": ["before", "after"]}, + "disconnected-native": {"immediate": ["before", "staged", "frame:handler", "frame:listener", "after"], "settled": ["before", "staged", "frame:handler", "frame:listener", "after"]}, + "disconnected-foreign": {"immediate": ["before", "staged", "frame:handler", "frame:listener", "after"], "settled": ["before", "staged", "frame:handler", "frame:listener", "after"]}, + "active-parser-append": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after"]}, + "active-parser-insert": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after"]}, + "active-parser-replace": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after"]}, + "active-fragment": {"immediate": ["before", "staged", "frame:handler", "frame:listener", "after"], "settled": ["before", "staged", "frame:handler", "frame:listener", "after"]}, + "active-cross-window": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after"]}, + "reinsert": {"immediate": ["before", "frame:handler", "frame:listener", "removed", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "removed", "frame:handler", "frame:listener", "after"]}, + "remove-in-onload": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after"]}, + "srcdoc": {"immediate": ["before", "after"], "settled": ["before", "after", "frame:handler", "frame:listener"]}, + "pending-replacement": {"immediate": ["before", "after"], "settled": ["before", "after", "frame:handler", "frame:listener"]}, + "navigate-in-onload": {"immediate": ["before", "frame:handler", "frame:listener", "after"], "settled": ["before", "frame:handler", "frame:listener", "after", "frame:handler", "frame:listener"]}, + "url-navigation": {"immediate": ["before", "after"], "settled": ["before", "after", "frame:handler", "frame:listener"]} + } +} diff --git a/moli-renderer-v8/tests/fixtures/iframe-load-delivery.js b/moli-renderer-v8/tests/fixtures/iframe-load-delivery.js new file mode 100644 index 0000000000..bb2774a4f2 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/iframe-load-delivery.js @@ -0,0 +1,124 @@ +// Shared by the Rust lifecycle regressions and the Chromium differential runner. +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + if (!document.body) root.appendChild(document.createElement('body')); + let events; + let resolveDone; + let remainingLoads; + let frame; + const parserDocument = () => new DOMParser().parseFromString( + '', 'text/html'); + const foreignFrame = () => parserDocument().querySelector('iframe'); + const watch = (target, owner, label = 'frame', afterLoad) => { + target.onload = function(event) { + if (this !== target || event.target !== target || event.currentTarget !== target + || this.ownerDocument !== owner || this.contentDocument.readyState !== 'complete') { + events.push('invalid-handler-target-or-document'); + } + events.push(`${label}:handler`); + if (afterLoad) afterLoad(); + }; + target.addEventListener('load', function(event) { + events.push(this === target && event.target === target && event.currentTarget === target + ? `${label}:listener` : 'invalid-listener-target'); + if (--remainingLoads === 0) resolveDone(); + }); + }; + const insert = (parent, target, method) => { + if (method === 'append') parent.appendChild(target); + else if (method === 'insert') parent.insertBefore(target, null); + else if (method === 'replace') { + const previous = parent.appendChild(parent.ownerDocument.createElement('p')); + parent.replaceChild(target, previous); + } else throw new Error(`Unknown insertion method: ${method}`); + }; + globalThis.iframeLoadDelivery = { + done: null, + start(name) { + events = []; + this.done = new Promise(resolve => { resolveDone = resolve; }); + remainingLoads = name.startsWith('inert-') ? 0 + : (name === 'reinsert' || name === 'navigate-in-onload' ? 2 : 1); + events.push('before'); + if (name.startsWith('inert-parser-') || name.startsWith('inert-html-')) { + const [, kind, method] = name.split('-'); + const doc = kind === 'parser' ? parserDocument() + : document.implementation.createHTMLDocument('inert'); + frame = doc.createElement('iframe'); + watch(frame, doc); + insert(doc.body, frame, method); + } else if (name === 'inert-adoption') { + const doc = document.implementation.createHTMLDocument('target'); + frame = foreignFrame(); + watch(frame, doc); + doc.body.appendChild(frame); + } else if (name.startsWith('disconnected-')) { + const parent = document.createElement('div'); + frame = name.endsWith('foreign') ? foreignFrame() : document.createElement('iframe'); + watch(frame, document); + parent.appendChild(frame); + events.push('staged'); + document.body.appendChild(parent); + } else if (name.startsWith('active-parser-')) { + frame = foreignFrame(); + watch(frame, document); + insert(document.body, frame, name.split('-')[2]); + } else if (name === 'active-fragment') { + const doc = parserDocument(); + const fragment = doc.createDocumentFragment(); + const parent = doc.createElement('div'); + frame = doc.querySelector('iframe'); + watch(frame, document); + parent.appendChild(frame); + fragment.appendChild(parent); + events.push('staged'); + document.body.appendChild(fragment); + } else if (name === 'active-cross-window') { + const host = document.body.appendChild(document.createElement('iframe')); + frame = host.contentDocument.createElement('iframe'); + watch(frame, document); + document.body.appendChild(frame); + } else if (name === 'reinsert') { + frame = foreignFrame(); + watch(frame, document); + document.body.appendChild(frame); + const previousDocument = frame.contentDocument; + frame.remove(); + events.push('removed'); + document.body.appendChild(frame); + if (frame.contentDocument === previousDocument) events.push('reused-detached-document'); + } else if (name === 'remove-in-onload') { + frame = foreignFrame(); + watch(frame, document, 'frame', () => frame.remove()); + document.body.appendChild(frame); + } else if (name === 'srcdoc' || name === 'pending-replacement') { + frame = foreignFrame(); + frame.srcdoc = 'original'; + watch(frame, document); + document.body.appendChild(frame); + if (name === 'pending-replacement') this.replace(); + } else if (name === 'navigate-in-onload') { + frame = foreignFrame(); + let first = true; + watch(frame, document, 'frame', () => { + if (first) { + first = false; + frame.srcdoc = 'replacement'; + } + }); + document.body.appendChild(frame); + } else if (name === 'url-navigation') { + frame = foreignFrame(); + document.body.appendChild(frame); + watch(frame, document); + frame.src = 'about:blank?navigation'; + } else throw new Error(`Unknown case: ${name}`); + events.push('after'); + if (remainingLoads === 0) resolveDone(); + return this.snapshot(); + }, + replace() { frame.srcdoc = 'replacement'; }, + snapshot() { return events.slice(); }, + bodyText() { return frame.contentDocument.body.textContent; } + }; +})(); diff --git a/scripts/iframe-load-chromium-differential.py b/scripts/iframe-load-chromium-differential.py new file mode 100644 index 0000000000..e5bf191459 --- /dev/null +++ b/scripts/iframe-load-chromium-differential.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Verify iframe load delivery using the same fixtures as the Rust regressions. + +uv run --project moli-benchmark python scripts/iframe-load-chromium-differential.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "moli-benchmark")) +# Reuse the existing CDP browser lifecycle (startup, target creation, teardown). +spec = importlib.util.spec_from_file_location( + "iframe_load_oracle", ROOT / "scripts/layout-phase4-chromium-differential.py" +) +oracle = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = oracle +spec.loader.exec_module(oracle) +FIXTURES = ROOT / "moli-renderer-v8/tests/fixtures" +EXPECTED = FIXTURES / "iframe-load-delivery.chromium.json" +CASES = [ + *(f"inert-{kind}-{method}" for kind in ("parser", "html") + for method in ("append", "insert", "replace")), + "inert-adoption", "disconnected-native", "disconnected-foreign", + *(f"active-parser-{method}" for method in ("append", "insert", "replace")), + "active-fragment", "active-cross-window", "reinsert", "remove-in-onload", + "srcdoc", "pending-replacement", "navigate-in-onload", "url-navigation", +] + + +async def evaluate(client, session_id, expression): + result, _ = await oracle.command(client, "Runtime.evaluate", { + "expression": expression, "returnByValue": True, "awaitPromise": True, + }, session_id=session_id) + if result.get("exceptionDetails"): + raise RuntimeError(result["exceptionDetails"]) + return result["result"].get("value") + + +async def measure(client, session_id, case, *, record): + _, messages = await oracle.command(client, "Page.navigate", { + "url": "data:text/html,iframe load probe", + }, session_id=session_id) + await oracle.wait_for_load(client, session_id, messages) + await evaluate(client, session_id, (FIXTURES / "iframe-load-delivery.js").read_text()) + immediate = await evaluate( + client, session_id, f"iframeLoadDelivery.start({json.dumps(case.name)})") + # Wait for the expected document loads, then cross task boundaries so a + # duplicate queued after the first event is visible in the final trace. + settled = await evaluate(client, session_id, """ + (async () => { + await iframeLoadDelivery.done; + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); + return iframeLoadDelivery.snapshot(); + })() + """) + if case.name in ("pending-replacement", "navigate-in-onload"): + body = await evaluate(client, session_id, "iframeLoadDelivery.bodyText()") + if body != "replacement": + raise AssertionError(f"{case.name}: replacement Document did not finish: {body!r}") + actual = {"immediate": immediate, "settled": settled} + if not record: + expected = json.loads(EXPECTED.read_text())["cases"][case.name] + if actual != expected: + raise AssertionError(f"{case.name}: expected {expected!r}, got {actual!r}") + return actual + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--chromium", type=Path, default=oracle.DEFAULT_CHROMIUM) + parser.add_argument("--record", action="store_true") + args = parser.parse_args() + oracle.fixture_cases = lambda: ( + oracle.Case(name, 800, 600, "", ()) for name in CASES + ) + oracle.measure_case = measure + report = asyncio.run(oracle.run(args.chromium.expanduser().resolve(), record=args.record)) + report.pop("tolerance_css_px") + if args.record: + lines = ["{", f' "product": {json.dumps(report["product"])},', + f' "revision": {json.dumps(report["revision"])},', ' "cases": {'] + items = list(report["cases"].items()) + lines.extend( + f" {json.dumps(name)}: {json.dumps(case)}" + ("," if i + 1 < len(items) else "") + for i, (name, case) in enumerate(items) + ) + EXPECTED.write_text("\n".join([*lines, " }", "}", ""])) + print(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + main()