Files
moli/moli-core/tests/javascript_url_navigation.rs
T
ldm0 503e15b74e fix(forms): schedule cancellable planned navigations
Keep submit and formdata events synchronous while navigation runs as a
DOM-manipulation task. Capture the request, submitter, target and history
handling; coalesce replaced submissions without extra task turns.

Preserve popup POST requests, cross-origin and sandbox boundaries, and
javascript URL replacement ordering. Follow form navigation that starts
during document creation and cover reentrant, adopted and same-URL forms.

Validation: cargo fmt --all; workspace all-target/all-feature clippy with
warnings denied; cargo nextest run --no-fail-fast (20,002 passed, 16 skipped).
Two additional official WPT cases pass with no passing-case regressions
across the form and Navigation API suites.
2026-10-11 18:06:42 +08:00

195 lines
7.8 KiB
Rust

use anyhow::Result;
use moli_core::runtime::{Browser, BrowserConfig};
use moli_test_support::FixtureServer;
use serde_json::Value;
use tokio::time::Duration;
use url::Url;
fn markup_url(server: &FixtureServer, markup: &str) -> String {
let mut url = Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap();
url.query_pairs_mut().append_pair("markup", markup);
url.into()
}
async fn child_javascript_url_navigation(action: &str, other_target: bool) -> Result<Value> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let source = format!(
r#"<!doctype html><body><form action='/net/echo'>
<input name='q' value='submitted'></form>
<a id='go' href='javascript:run()'>go</a><script>
function run() {{
{action}
parent.scriptContinued = true;
return '<body id="completion">replacement';
}}
</script>"#
);
let parent = format!(
r#"<!doctype html><body><script>
let finish;
const loaded = new Promise(resolve => finish = resolve);
let otherLoaded = Promise.resolve(null);
if ({other_target}) {{
const other = document.createElement('iframe');
other.name = 'other';
otherLoaded = new Promise(resolve => {{
other.onload = () => {{
if (other.contentWindow.location.pathname === '/net/echo')
resolve(JSON.parse(other.contentDocument.body.textContent));
}};
}});
document.body.append(other);
}}
window.finished = Promise.all([loaded, otherLoaded]);
const frame = document.createElement('iframe');
frame.name = 'source';
frame.onload = () => {{
const originalDocument = frame.contentDocument;
frame.onload = () => finish({{
path: frame.contentWindow.location.pathname,
search: frame.contentWindow.location.search,
body: frame.contentDocument.body.textContent,
bodyId: frame.contentDocument.body.id,
documentChanged: frame.contentDocument !== originalDocument,
scriptContinued
}});
setTimeout(() => frame.contentDocument.getElementById('go').click(), 0);
}};
frame.src = {};
document.body.append(frame);
</script>"#,
serde_json::to_string(&markup_url(&server, &source))?.replace("</script>", "<\\/script>")
);
let result = tokio::time::timeout(Duration::from_secs(10), async {
let mut page = browser.fetch(&markup_url(&server, &parent)).await?;
page.evaluate_runtime_expression_with_await_async(
"finished.then(value => JSON.stringify(value))",
true,
)
.await
})
.await??;
let result: Value = serde_json::from_str(result["value"].as_str().unwrap())?;
server.shutdown().await;
assert_eq!(result[0]["documentChanged"], true, "{action}: {result}");
assert_eq!(result[0]["scriptContinued"], true, "{action}: {result}");
Ok(result)
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_string_does_not_overtake_its_form_submission() -> Result<()> {
for method in ["get", "post"] {
for target in ["", "_self", "source"] {
let action = format!(
"document.forms[0].method = {method:?}; \
document.forms[0].target = {target:?}; document.forms[0].submit();"
);
let result = child_javascript_url_navigation(&action, false).await?;
// Observe the first load after the javascript URL. A temporary
// replacement document would fire an extra load at the old URL.
assert_eq!(result[0]["path"], "/net/echo", "{action}: {result}");
let response: Value = serde_json::from_str(result[0]["body"].as_str().unwrap())?;
assert_eq!(response["method"], method.to_uppercase());
if method == "post" {
assert_eq!(result[0]["search"], "");
} else {
assert_eq!(result[0]["search"], "?q=submitted");
}
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_string_does_not_overtake_its_location_navigation() -> Result<()> {
let result =
child_javascript_url_navigation("location.href = '/net/echo?q=location';", false).await?;
assert_eq!(result[0]["path"], "/net/echo", "{result}");
assert_eq!(result[0]["search"], "?q=location");
let result = child_javascript_url_navigation(
"location.href = \"javascript:'<body id=successor>successor'\";",
false,
)
.await?;
assert_eq!(result[0]["bodyId"], "successor", "{result}");
assert_eq!(result[0]["body"], "successor");
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_string_survives_navigation_to_another_frame() -> Result<()> {
for method in ["get", "post"] {
let action = format!(
"document.forms[0].method = {method:?}; \
document.forms[0].target = 'other'; document.forms[0].submit();"
);
let result = child_javascript_url_navigation(&action, true).await?;
assert_eq!(result[0]["bodyId"], "completion", "{action}: {result}");
assert_eq!(result[0]["body"], "replacement");
assert_eq!(result[1]["method"], method.to_uppercase());
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_string_survives_without_a_new_cross_document_navigation() -> Result<()>
{
for action in [
"document.body.dataset.changed = 'yes';",
"location.hash = 'fragment';",
] {
let result = child_javascript_url_navigation(action, false).await?;
assert_eq!(result[0]["bodyId"], "completion", "{action}: {result}");
assert_eq!(result[0]["body"], "replacement");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn canceled_planned_form_navigation_suppresses_javascript_string_replacement() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let source = markup_url(
&server,
r#"<!doctype html><body id=original>
<form action='/net/echo'><input name=q value=submitted></form>
<a id=go href="javascript:(document.forms[0].submit(), '<body id=completion>replacement')">go</a>"#,
);
let parent = format!(
r#"<!doctype html><body><script>
window.finished = new Promise(resolve => {{
const frame = document.createElement('iframe');
frame.onload = () => {{
const original = frame.contentDocument;
const nav = frame.contentWindow.navigation;
nav.onnavigate = event => event.preventDefault();
nav.onnavigateerror = event => resolve({{
sameDocument: frame.contentDocument === original,
bodyId: frame.contentDocument.body.id,
error: event.error.name
}});
setTimeout(() => original.getElementById('go').click(), 0);
}};
frame.src = {};
document.body.appendChild(frame);
}});
</script>"#,
serde_json::to_string(&source)?.replace("</script>", "<\\/script>")
);
let result = tokio::time::timeout(Duration::from_secs(10), async {
let mut page = browser.fetch(&markup_url(&server, &parent)).await?;
page.evaluate_runtime_expression_with_await_async("finished.then(JSON.stringify)", true)
.await
})
.await??;
let observed: Value = serde_json::from_str(result["value"].as_str().unwrap())?;
assert_eq!(
observed,
serde_json::json!({"sameDocument":true,"bodyId":"original","error":"AbortError"})
);
server.shutdown().await;
Ok(())
}