Files
moli/moli-core/tests/scripts/runtime_modulepreload.rs
T
ldm0 41301ac9b9 fix(modules): reuse module maps for runtime script roots
Consult the module map before fetching runtime module script roots while
preserving script-element Service Worker filtering and integrity checks.
Keep shared fetches alive across document.open so imports in the retained
realm settle without reviving retired script elements or their events.
2026-10-11 18:06:44 +08:00

387 lines
16 KiB
Rust

use super::*;
async fn check_runtime_modulepreloads(child: bool) -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/runtime-modulepreload.html", servers.origin))
.await?;
let probe = format!(
r#"(async () => {{
if (document.readyState !== 'complete')
await new Promise(resolve => addEventListener('load', resolve, {{once:true}}));
const check = (value, message) => {{ if (!value) throw new Error(message); }};
const preload = (href, as, integrity) => new Promise((resolve, reject) => {{
const link = document.createElement('link');
link.rel = 'modulepreload'; link.as = as; link.href = href;
if (integrity !== undefined) link.integrity = integrity;
const timeout = setTimeout(() => reject(new Error('no preload event: ' + href)), 3000);
const done = event => {{ clearTimeout(timeout); resolve(event.type); }};
link.onload = done; link.onerror = done;
document.head.append(link);
}});
const paths = ['/echo-origin.js?runtime', '/module.json?runtime',
'/style.css?runtime', '/text-module.txt?runtime'];
const types = ['script', 'json', 'style', 'text'];
const events = await Promise.all(paths.map((path, i) => preload(path, types[i])));
check(events.every(event => event === 'load'), 'preload load events: ' + events);
check(!globalThis.sriExecutions, 'preload evaluated JavaScript');
const js = await import(paths[0]);
check(js === await import(paths[0]), 'module namespace identity');
check(sriExecutions === 1, 'JavaScript must evaluate once');
check((await import(paths[1], {{with:{{type:'json'}}}})).default.answer === 42, 'JSON');
check((await import(paths[2], {{with:{{type:'css'}}}})).default instanceof CSSStyleSheet, 'CSS');
check((await import(paths[3], {{with:{{type:'text'}}}})).default === {script}, 'text');
check(await preload(paths[0], 'script') === 'load', 'already fetched module event');
const failures = await Promise.all([
preload('/missing.js?runtime', 'script'),
preload({cross} + '/script.js?runtime', 'script'),
preload('/echo-origin.js?bad-integrity', 'script', 'sha384-AAAA'),
preload('/echo-origin.js?map-integrity', 'script'),
preload('/echo-origin.js?invalid-as', 'image')
]);
check(failures.every(event => event === 'error'), 'preload errors: ' + failures);
return true;
}})()"#,
script = serde_json::to_string(SCRIPT)?,
cross = serde_json::to_string(&servers.cross_origin)?,
);
let expression = if child {
format!(
r#"(async () => {{
const frame = document.createElement('iframe');
frame.src = '/runtime-modulepreload.html?child';
await new Promise(resolve => {{ frame.onload = resolve; document.body.append(frame); }});
return frame.contentWindow.eval({});
}})()"#,
serde_json::to_string(&probe)?
)
} else {
probe
};
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(&expression, true),
)
.await??;
assert_eq!(
result["value"],
true,
"child={child}: {result}; requests={:?}",
servers.requests.lock()
);
let requests = servers.requests.lock();
for (path, destination) in [
("/echo-origin.js?runtime", "script"),
("/module.json?runtime", "json"),
("/style.css?runtime", "style"),
("/text-module.txt?runtime", "text"),
("/missing.js?runtime", "script"),
("/script.js?runtime", "script"),
("/echo-origin.js?bad-integrity", "script"),
("/echo-origin.js?map-integrity", "script"),
] {
let matching: Vec<_> = requests
.iter()
.filter(|request| request.path == path)
.collect();
assert_eq!(matching.len(), 1, "child={child}, {path}: {requests:?}");
assert_eq!(
matching[0].sec_fetch_dest.as_deref(),
Some(destination),
"{path}"
);
assert_eq!(
matching[0].sec_fetch_mode.as_deref(),
Some("cors"),
"{path}"
);
}
assert!(
!requests
.iter()
.any(|request| request.path.contains("invalid-as"))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_modulepreloads_fetch_after_load_and_reuse_the_main_module_map() -> Result<()> {
check_runtime_modulepreloads(false).await
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_modulepreloads_fetch_after_load_and_reuse_the_child_module_map() -> Result<()> {
check_runtime_modulepreloads(true).await
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_consumers_share_the_main_module_map() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let probe = r#"(async () => {
const check = (value, message) => { if (!value) throw new Error(message); };
const attach = (tag, url, attributes = {}) => new Promise((resolve, reject) => {
const element = document.createElement(tag);
if (tag === 'link') { element.rel = 'modulepreload'; element.href = url; }
else { element.type = 'module'; element.src = url; }
Object.assign(element, attributes);
const timeout = setTimeout(() => reject(new Error('no event: ' + url)), 3000);
element.onload = element.onerror = event => { clearTimeout(timeout); resolve(event.type); };
document.head.append(element);
});
for (const [name, warmup, ordered] of [
['preloaded', 'preload', false],
['ordered', 'preload', true],
['credentials', 'preload', false],
['integrity', 'preload', false],
['imported', 'import', false],
['evaluated', 'script', false],
['concurrent', 'concurrent-preload', false],
['cold', 'none', false]
]) {
const url = '/echo-origin.js?consumer-' + name;
const before = globalThis.sriExecutions || 0;
const pending = [];
if (warmup === 'preload') {
check(await attach('link', url, {crossOrigin: 'anonymous'}) === 'load', name + ' preload');
check((globalThis.sriExecutions || 0) === before, name + ' premature evaluation');
} else if (warmup === 'import') await import(url);
else if (warmup === 'script') check(await attach('script', url) === 'load', name + ' first script');
else if (warmup === 'concurrent-preload') pending.push(attach('link', url));
const attributes = {async: !ordered};
if (name === 'credentials') attributes.crossOrigin = 'use-credentials';
// The already-fetched module is reused before new fetch options are examined.
if (name === 'integrity') attributes.integrity = 'sha384-AAAA';
pending.push(attach('script', url, attributes), attach('script', url, attributes));
check((await Promise.all(pending)).every(event => event === 'load'), name + ' terminal events');
check(sriExecutions === before + 1, name + ' must evaluate exactly once');
const namespace = await import(url);
check(namespace === await import(url), name + ' namespace identity');
check(sriExecutions === before + 1, name + ' import must reuse evaluation');
}
// Fetch policy must still be enforced on roots that are absent from the map.
check(await attach('script', '/echo-origin.js?consumer-bad-sri', {integrity:'sha384-AAAA'}) === 'error', 'fresh SRI');
check(await attach('script', '/missing.js?consumer-missing') === 'error', 'fresh 404');
const beforeClassic = sriExecutions;
for (let i = 0; i < 2; i++)
check(await attach('script', '/echo-origin.js?consumer-classic', {type:''}) === 'load', 'classic load');
check(sriExecutions === beforeClassic + 2, 'classic scripts must each evaluate');
return true;
})()"#;
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(probe, true),
)
.await??;
assert_eq!(
result["value"],
true,
"{result}; requests={:?}",
servers.requests.lock()
);
let requests = servers.requests.lock();
for name in [
"preloaded",
"ordered",
"credentials",
"integrity",
"imported",
"evaluated",
"concurrent",
"cold",
"bad-sri",
"missing",
"classic",
] {
let path = if name == "missing" {
"/missing.js?consumer-missing".to_owned()
} else {
format!("/echo-origin.js?consumer-{name}")
};
let matching: Vec<_> = requests
.iter()
.filter(|request| request.path == path)
.collect();
assert_eq!(
matching.len(),
if name == "classic" { 2 } else { 1 },
"{path}: {requests:?}"
);
assert_eq!(
matching[0].sec_fetch_dest.as_deref(),
Some("script"),
"{path}"
);
assert_eq!(
matching[0].sec_fetch_mode.as_deref(),
Some(if name == "classic" { "no-cors" } else { "cors" }),
"{path}"
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_map_reservations_preserve_service_worker_responses() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let cases = serde_json::json!([
{"name":"synthetic", "src":"/sw-default.js", "type":"module", "integrity":INTEGRITY, "expected":"load"},
{"name":"cors", "src":"/sw-cors.js", "type":"module", "integrity":INTEGRITY, "expected":"load"},
{"name":"opaque", "src":"/sw-opaque.js", "type":"module", "expected":"error"}
]);
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, true), true),
)
.await??;
assert_integrity_results(result, &cases);
let result = tokio::time::timeout(
Duration::from_secs(10),
page.evaluate_runtime_expression_with_await_async(
r#"(async () => {
const before = sriExecutions;
await import('/sw-default.js');
const script = document.createElement('script');
script.type = 'module'; script.src = '/sw-default.js';
await new Promise((resolve, reject) => {
script.onload = resolve; script.onerror = reject; document.head.append(script);
});
return sriExecutions === before;
})()"#,
true,
),
)
.await??;
assert_eq!(result["value"], true, "{result}");
assert!(
!servers
.requests
.lock()
.iter()
.any(|request| request.path.starts_with("/sw-")),
"intercepted roots must not reach the network"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_fetch_settles_the_retained_map_after_document_open() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let result = tokio::time::timeout(Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(r#"(async () => {
const url = '/echo-origin.js?consumer-open';
globalThis.retiredModuleEvents = 0;
const script = document.createElement('script'); script.type = 'module'; script.src = url;
script.onload = script.onerror = () => retiredModuleEvents++;
document.head.append(script);
await fetch('/module-response-started');
document.open(); document.write('<!doctype html><head></head><body>replacement'); document.close();
const imported = import(url);
await fetch('/module-response-release');
await imported;
return JSON.stringify({executions: sriExecutions, retiredEvents: retiredModuleEvents,
entries: performance.getEntriesByName(new URL(url, location.href).href).length});
})()"#, true),
).await??;
assert!(result["value"].is_string(), "{result}");
let actual: serde_json::Value = serde_json::from_str(result["value"].as_str().unwrap())?;
assert_eq!(
actual,
serde_json::json!({"executions":1,"retiredEvents":0,"entries":0})
);
assert_eq!(
servers
.requests
.lock()
.iter()
.filter(|request| request.path == "/echo-origin.js?consumer-open")
.count(),
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn runtime_modulepreloads_keep_document_maps_and_retire_queued_child_starts() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let result = tokio::time::timeout(
Duration::from_secs(15),
page.evaluate_runtime_expression_with_await_async(
r#"(async () => {
if (document.readyState !== 'complete')
await new Promise(resolve => addEventListener('load', resolve, {once:true}));
const preload = (doc, path) => new Promise((resolve, reject) => {
const link = doc.createElement('link');
link.rel = 'modulepreload'; link.href = path;
const timeout = setTimeout(() => reject(new Error('no preload event: ' + path)), 3000);
link.onload = () => { clearTimeout(timeout); resolve(); };
link.onerror = () => { clearTimeout(timeout); reject(new Error('preload error: ' + path)); };
doc.head.append(link);
});
const path = '/echo-origin.js?separate-maps';
await preload(document, path);
const frame = document.createElement('iframe');
frame.src = '/page.html?child';
await new Promise(resolve => { frame.onload = resolve; document.body.append(frame); });
const child = frame.contentDocument;
await preload(child, path);
let retiredEvents = 0;
const oldLink = child.createElement('link');
oldLink.rel = 'modulepreload'; oldLink.href = '/echo-origin.js?retired-child';
oldLink.onload = oldLink.onerror = () => retiredEvents++;
child.head.append(oldLink);
// Retire the captured Document before its queued start can run.
child.open(); child.write('<!doctype html><head></head><body>replacement'); child.close();
await preload(child, '/echo-origin.js?replacement-child');
if (retiredEvents !== 0) throw new Error('retired child link received an event');
document.open(); document.write('<!doctype html><head></head><body>replacement'); document.close();
await preload(document, path);
if (globalThis.sriExecutions) throw new Error('preload executed');
await import(path);
if (sriExecutions !== 1) throw new Error('replacement module evaluation');
return true;
})()"#,
true,
),
).await??;
assert_eq!(
result["value"],
true,
"{result}; requests={:?}",
servers.requests.lock()
);
let requests = servers.requests.lock();
for (path, expected) in [
// document.open replaces the tree but retains the Window's module map.
("/echo-origin.js?separate-maps", 2),
("/echo-origin.js?retired-child", 0),
("/echo-origin.js?replacement-child", 1),
] {
assert_eq!(
requests
.iter()
.filter(|request| request.path == path)
.count(),
expected,
"{path}: {requests:?}"
);
}
Ok(())
}