diff --git a/moli-core/tests/fixtures/document-open-root-url.js b/moli-core/tests/fixtures/document-open-root-url.js new file mode 100644 index 0000000000..936dd9a150 --- /dev/null +++ b/moli-core/tests/fixtures/document-open-root-url.js @@ -0,0 +1,30 @@ +(async () => { + const frame = document.createElement('iframe'); + frame.src = '/compat/child-dynamic-markup-document?markup=%3Cbody%3Esource#source-fragment'; + const loaded = new Promise(resolve => frame.onload = resolve); + document.body.append(frame); + await loaded; + const child = frame.contentWindow; + return new Promise(resolve => { + child.finish = resolve; + child.target = document; + child.targetWindow = window; + child.setTimeout(child.Function(` + const done = finish, doc = target, win = targetWindow; + const expected = document.URL.split('#')[0]; + const oldLength = win.history.length; + const returned = doc.open(); + const observations = {url: doc.URL, uri: doc.documentURI, base: doc.baseURI, + location: win.location.href, length: win.history.length, identity: returned === doc}; + const failures = []; + for (const field of ['url', 'uri', 'base', 'location']) { + if (observations[field] !== expected) failures.push({field, actual: observations[field], expected}); + } + if (observations.length !== oldLength) failures.push({field: 'length', actual: observations.length, expected: oldLength}); + if (!observations.identity) failures.push({field: 'identity', actual: false, expected: true}); + doc.write('
replacement'); + doc.close(); + done({checks: 6, failures, observations}); + `), 0); + }); +})() diff --git a/moli-core/tests/fixtures/document-open-url.js b/moli-core/tests/fixtures/document-open-url.js new file mode 100644 index 0000000000..4719082cee --- /dev/null +++ b/moli-core/tests/fixtures/document-open-url.js @@ -0,0 +1,97 @@ +(async () => { + let checks = 0; + const failures = [], observations = []; + const check = (name, actual, expected) => { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) failures.push({name, actual, expected}); + }; + const frame = async (src) => { + const node = document.createElement('iframe'); + if (src) node.src = src; + const loaded = new Promise(resolve => node.onload = resolve); + document.body.append(node); + await loaded; + return node; + }; + const snapshot = (doc, win) => ({ + url: doc.URL, uri: doc.documentURI, base: doc.baseURI, + location: win.location.href, length: win.history.length, state: win.history.state, + navigation: win.navigation?.currentEntry?.url ?? null, + link: new win.URL('relative', doc.baseURI).href, + }); + const parentURL = new URL(document.URL); parentURL.hash = ''; + for (const mode of ['blank-open', 'loaded-open', 'loaded-write', 'base-open', 'borrowed-open', 'self-open']) { + const f = await frame(mode === 'blank-open' ? null : '/compat/child-dynamic-markup-document?markup=%3C!doctype%20html%3E%3Cbody%3Etarget#target'); + const d = f.contentDocument, w = f.contentWindow; + if (mode !== 'blank-open') w.history.replaceState({kept: true, map: new w.Map([['key', 'value']])}, ''); + const before = snapshot(d,w); + const base = document.createElement('base'); + if (mode === 'base-open') {base.href = '/different-base/'; document.head.append(base);} + let returned; + if (mode === 'loaded-write') {d.write('replacement
');} + else if (mode === 'borrowed-open') {returned = Document.prototype.open.call(d);} + else if (mode === 'self-open') { + await new Promise(resolve => { + w.done = result => {returned = result; resolve();}; + w.setTimeout(w.Function('done(document.open())'), 0); + }); + } else {returned = d.open();} + const after = snapshot(d,w); + const expectedURL = mode === 'self-open' ? before.url : parentURL.href; + for (const field of ['url','uri','location']) check(mode + ':' + field, after[field], expectedURL); + check(mode + ':base', after.base, expectedURL); + check(mode + ':history-length', after.length, before.length); + check(mode + ':history-state', after.state, before.state); + if (mode !== 'loaded-write') check(mode + ':identity', returned === d, true); + check(mode + ':state-identity', after.state === before.state, true); + if (mode !== 'blank-open') check(mode + ':structured-state', after.state.map.get('key'), 'value'); + observations.push({mode,before,after}); + d.close(); + if (mode === 'loaded-open') { + w.history.pushState(null, '', new URL('?after-open', expectedURL).href); + await new Promise(resolve => { + w.addEventListener('popstate', resolve, {once: true}); + w.history.back(); + }); + check(mode + ':back-url', d.URL, expectedURL); + check(mode + ':back-location', w.location.href, expectedURL); + check(mode + ':back-structured-state', w.history.state.map.get('key'), 'value'); + } + f.remove(); base.remove(); + } + for (const mode of ['nested-call', 'timer', 'microtask']) { + const source = await frame('/compat/child-dynamic-markup-document?markup=%3Cbody%3Esource#source-fragment'); + const target = await frame('/compat/child-dynamic-markup-document?markup=%3Cbody%3Etarget#target-fragment'); + const w = source.contentWindow, d = target.contentDocument; + w.target = d; + const expected = mode === 'nested-call' ? parentURL.href : w.document.URL.split('#')[0]; + if (mode === 'nested-call') { + w.Function('target.open()')(); + } else { + await new Promise(resolve => { + w.done = resolve; + const action = w.Function('target.open(); done();'); + if (mode === 'timer') w.setTimeout(action, 0); + else w.Promise.resolve().then(action); + }); + } + const observed = snapshot(d, target.contentWindow); + for (const field of ['url', 'uri', 'base', 'location']) check(mode + ':' + field, observed[field], expected); + d.close(); source.remove(); target.remove(); + } + for (const mode of ['removed', 'windowless', 'old-document']) { + const f = await frame(); + let d = f.contentDocument; + if (mode === 'removed') f.remove(); + if (mode === 'windowless') d = d.implementation.createHTMLDocument(''); + if (mode === 'old-document') { + const loaded = new Promise(resolve => f.onload = resolve); + f.src = '/compat/child-dynamic-markup-document?markup=replacement'; await loaded; + } + const before = d.URL; + check(mode + ':identity', d.open() === d, true); + check(mode + ':url', d.URL, before); + d.close(); f.remove(); + } + return {checks, failures, observations}; +})() diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index 82c5b0a764..accb861991 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -34,6 +34,9 @@ mod module_document_write; #[path = "scripts/document_close.rs"] mod document_close; +#[path = "scripts/document_open_url.rs"] +mod document_open_url; + #[path = "scripts/document_write_insertion.rs"] mod document_write_insertion; diff --git a/moli-core/tests/scripts/document_open_url.rs b/moli-core/tests/scripts/document_open_url.rs new file mode 100644 index 0000000000..a257fe2bc3 --- /dev/null +++ b/moli-core/tests/scripts/document_open_url.rs @@ -0,0 +1,38 @@ +use super::*; + +async fn check_document_open_urls(script: &str, checks: u64) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document"))?; + url.query_pairs_mut() + .append_pair("markup", "entry"); + url.set_fragment(Some("entry-fragment")); + let mut page = browser.fetch(url.as_str()).await?; + let result = tokio::time::timeout( + Duration::from_secs(10), + page.evaluate_runtime_expression_with_await_async( + &format!("({script}).then(JSON.stringify)"), + true, + ), + ) + .await??; + let observed: serde_json::Value = serde_json::from_str( + result["value"] + .as_str() + .expect("document.open URL observations"), + )?; + assert_eq!(observed["checks"], checks); + assert_eq!(observed["failures"], serde_json::json!([])); + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn document_open_updates_active_document_urls_from_entry_document() -> Result<()> { + check_document_open_urls(include_str!("../fixtures/document-open-url.js"), 73).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn document_open_updates_root_url_from_child_entry_document() -> Result<()> { + check_document_open_urls(include_str!("../fixtures/document-open-root-url.js"), 6).await +} diff --git a/moli-protocol/src/domains/page/tests/document_content.rs b/moli-protocol/src/domains/page/tests/document_content.rs index 612165ce2c..9afe679dc6 100644 --- a/moli-protocol/src/domains/page/tests/document_content.rs +++ b/moli-protocol/src/domains/page/tests/document_content.rs @@ -408,6 +408,72 @@ async fn set_document_content_preserves_history_length_and_state() { server.abort(); } +#[tokio::test(flavor = "multi_thread")] +async fn document_open_keeps_reentrant_history_url_in_frame_tree() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let app = axum::Router::new() + .route( + "/history", + axum::routing::get(|| async { + axum::response::Html( + "", + ) + }), + ) + .route( + "/source", + axum::routing::get(|| async { axum::response::Html("entry") }), + ); + axum::serve(listener, app).await.unwrap(); + }); + + for child_entry in [false, true] { + let mut ctx = TestContext::new(); + install_document_content_test_page(&mut ctx, &format!("http://{addr}/history")).await; + ctx.process_async(json!({ + "id": 17, + "method": "Runtime.evaluate", + "sessionId": "SID-1", + "params": { + "returnByValue": true, + "awaitPromise": true, + "expression": format!(r#"new Promise(resolve => {{ + navigation.addEventListener('currententrychange', () => {{ + history.replaceState({{after: true}}, '', '/after-open'); + }}, {{once: true}}); + const source = {child_entry} ? document.querySelector('iframe').contentWindow : window; + source.finishOpen = resolve; + source.targetDocument = document; + source.setTimeout(source.Function(` + const finish = finishOpen, target = targetDocument; + target.open(); + target.write('replacement'); + target.close(); + finish(target.URL); + `), 0); + }})"#), + }, + })) + .await; + wait_until_scheduler_message( + &mut ctx, + "document.open reentrant history response", + |message| message["id"] == json!(17) && message["sessionId"] == json!("SID-1"), + ) + .await; + let response = take_response_by_id(&mut ctx, 17); + let expected = json!(format!("http://{addr}/after-open")); + assert_eq!( + response["result"]["result"]["value"], expected, + "{response:?}" + ); + assert_eq!(frame_tree(&mut ctx, 18).await["frame"]["url"], expected); + } + server.abort(); +} + // Ported from WPT opening-the-input-stream/mutation-observer.window.js and // verified against Chromium's Page.setDocumentContent path. Unlike a bare // document.open(), SetContent also exposes the parser's subsequent additions. diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index e8ff5115dc..d22f4fd435 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -136,6 +136,7 @@ pub(crate) use crypto::{ }; pub(crate) use css_fontface_runtime::{load_font_faces_for_family, rebuild_font_face_set_faces}; pub(crate) use form_navigation::FormNavigationHistory; +pub(crate) use history_mutation::update_history_for_document_open; pub(crate) use location_navigation::{ LocationNavigationKind, dispatch_top_level_form_navigation_event, dispatch_top_level_navigation_event_with_source_element, meta_refresh_navigation_kind, diff --git a/moli-renderer-v8/src/context_bootstrap/history_mutation.rs b/moli-renderer-v8/src/context_bootstrap/history_mutation.rs index 6697702f61..c9d0c8a4d1 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_mutation.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_mutation.rs @@ -24,13 +24,68 @@ use super::navigation_result::{ }; use super::navigation_serialize::sync_child_navigation_entry_seed_from_owner; use super::navigation_window::{ - child_browsing_context_handle_for_runtime_owner, - runtime_window_is_global, window_location_for_holder, window_navigation_for_holder, + child_browsing_context_handle_for_runtime_owner, runtime_window_is_global, + window_history_for_holder, window_location_for_holder, window_navigation_for_holder, }; use super::*; use crate::webidl; use moli_page_types::SameDocumentHistoryUpdate; +/// The URL/history update used by document.open() replaces the current entry +/// without running history API argument conversion or firing a navigate event. +/// A missing serialized state preserves both history.state and its snapshot. +pub(crate) fn update_history_for_document_open<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, + url: &url::Url, +) { + let Some(location) = window_location_for_holder(scope, window) else { + return; + }; + sync_location_object(scope, location, url.as_str()); + let Some(history) = window_history_for_holder(scope, window) else { + return; + }; + let Some(entries) = history_entries(scope, history) else { + return; + }; + let index = history_index(scope, history); + let Some(previous) = entries + .get_index(scope, index) + .and_then(|value| v8::Local::