Something went wrong. Try again.
we (web engine): Experimental web browser project to understand the limits of Claude
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955//! Script element loading and execution.//!//! Walks the DOM for `<script>` elements, fetches external scripts, and//! executes them in a shared JS VM with DOM access.
use crate::csp::DirectiveType;use crate::import_map::ImportMap;use crate::loader::{Resource, ResourceLoader, ResourceRequestType};use we_dom::{Document, NodeId};use we_js::ast::Program;use we_js::compiler;use we_js::parser::Parser;use we_js::vm::{ConsoleOutput, Vm};use we_net::referrer::ReferrerPolicy;use we_url::{Origin, Url};
/// Information about a `<script>` element extracted from the DOM.struct ScriptInfo { /// Inline script text (from child text nodes). text: Option<String>, /// `src` attribute value (external script URL). src: Option<String>, /// Whether the `defer` attribute is present. defer: bool, /// Whether the `async` attribute is present. async_attr: bool, /// The `type` attribute value, if any. type_attr: Option<String>, /// Element-level referrer policy override (`referrerpolicy` attribute). referrer_policy: Option<ReferrerPolicy>, /// The `nonce` attribute value, if any (for CSP nonce matching). nonce: Option<String>,}
/// How a `<script>` element should be interpreted, based on its `type`/// attribute.#[derive(Clone, Copy, PartialEq, Eq, Debug)]enum ScriptKind { /// A classic script: no `type`, an empty `type`, or a JavaScript MIME /// type. Executed with the script (non-strict, sloppy) parser. Classic, /// An ES module script (`type="module"`). Parsed with the module parser /// (strict mode, `import`/`export` allowed) and always deferred until /// after classic parser-inserted scripts. Module, /// Any other `type` (e.g. `text/template`, `importmap`, `application/json`) /// — not executed as script. Skip,}
/// Classify a `<script>` element from its `type` attribute.////// Per the HTML spec, a script is *classic* if it has no `type`, an empty/// `type`, or a JavaScript MIME type; it is a *module* script if the type is/// an ASCII case-insensitive match for `module`; anything else (data blocks/// such as `importmap`, `application/json`, `text/template`) is not executed.fn classify_script(type_attr: &Option<String>) -> ScriptKind { match type_attr { None => ScriptKind::Classic, Some(t) => { let t = t.trim().to_ascii_lowercase(); if t.is_empty() || t == "text/javascript" || t == "application/javascript" || t == "application/x-javascript" || t == "text/ecmascript" || t == "application/ecmascript" { ScriptKind::Classic } else if t == "module" { ScriptKind::Module } else { ScriptKind::Skip } } }}
/// True when a `<script>`'s `type` is an ASCII case-insensitive match for/// `importmap`.fn is_import_map_type(type_attr: &Option<String>) -> bool { matches!(type_attr, Some(t) if t.trim().eq_ignore_ascii_case("importmap"))}
/// Find the page's import map: the first inline `<script type="importmap">` in/// document order, parsed against the document base URL. Returns the default/// (empty) map when there is none. Per the spec only the first import map is/// honoured; external import maps (`src`) are not supported and are ignored.fn extract_import_map(scripts: &[(NodeId, ScriptInfo)], base_url: &Url) -> ImportMap { for (_node, info) in scripts { if is_import_map_type(&info.type_attr) { if let Some(ref text) = info.text { return ImportMap::parse(text, base_url); } // First import map found but with no inline body; stop looking // (a later import map would be invalid anyway). break; } } ImportMap::default()}
/// Collect text content from child text nodes of an element.fn collect_text_content(doc: &Document, node: NodeId) -> String { let mut text = String::new(); for child in doc.children(node) { if let Some(data) = doc.text_content(child) { text.push_str(data); } } text}
/// Walk the DOM tree in document order, collecting `<script>` elements.fn collect_script_nodes(doc: &Document, node: NodeId, result: &mut Vec<NodeId>) { if doc.tag_name(node) == Some("script") { result.push(node); } for child in doc.children(node) { collect_script_nodes(doc, child, result); }}
/// Extract script info from a `<script>` element node.fn extract_script_info(doc: &Document, node: NodeId) -> ScriptInfo { let src = doc.get_attribute(node, "src").map(|s| s.to_string()); let defer = doc.get_attribute(node, "defer").is_some(); let async_attr = doc.get_attribute(node, "async").is_some(); let type_attr = doc.get_attribute(node, "type").map(|s| s.to_string()); let referrer_policy = doc .get_attribute(node, "referrerpolicy") .and_then(ReferrerPolicy::parse); let nonce = doc.get_attribute(node, "nonce").map(|s| s.to_string());
let text = if src.is_none() { let content = collect_text_content(doc, node); if content.is_empty() { None } else { Some(content) } } else { None };
ScriptInfo { text, src, defer, async_attr, type_attr, referrer_policy, nonce, }}
/// Fetch the text of an external script with SOP enforcement.fn fetch_script_text( loader: &mut ResourceLoader, src: &str, base_url: &Url, document_origin: &Origin, element_policy: Option<ReferrerPolicy>,) -> Option<String> { let url = match resolve_script_url(src, base_url) { Ok(u) => u, Err(_) => { eprintln!("[script] invalid URL: {src}"); return None; } }; match loader.fetch_subresource_with_referrer_policy( &url, document_origin, ResourceRequestType::Script, element_policy, ) { Ok(Resource::Script { text, .. }) => Some(text), Ok(Resource::Other { data, .. }) => { // Try decoding as UTF-8 (servers may not set correct MIME type). String::from_utf8(data).ok() } Ok(_) => { eprintln!("[script] unexpected resource type for {src}"); None } Err(e) => { eprintln!("[script] failed to fetch {src}: {e}"); None } }}
fn resolve_script_url(src: &str, base_url: &Url) -> Result<Url, ()> { we_url::Url::parse_with_base(src, base_url) .or_else(|_| we_url::Url::parse(src)) .map_err(|_| ())}
fn install_module_specifier_resolver(import_map: &ImportMap, base_url: &Url) { let import_map = import_map.clone(); let base_url = base_url.clone(); we_js::fetch::set_module_specifier_resolver(move |specifier| { import_map .resolve(specifier, &base_url) .map(|url| url.serialize()) });}
/// Parse a script as a classic script or an ES module per `kind`.fn parse_source(source: &str, label: &str, kind: ScriptKind) -> Option<Program> { let parsed = match kind { ScriptKind::Module => Parser::parse_module(source), _ => Parser::parse(source), }; match parsed { Ok(ast) => Some(ast), Err(e) => { eprintln!( "[script] parse error in {label}: {e}\n preview: {}", script_preview(source) ); None } }}
/// Compile and execute an already-parsed script. Errors are logged, not/// propagated.fn execute_program(vm: &mut Vm, program: &Program, source: &str, label: &str) { let func = match compiler::compile(program) { Ok(f) => f, Err(e) => { eprintln!( "[script] compile error in {label}: {e}\n preview: {}", script_preview(source) ); return; } };
if let Err(e) = vm.execute(&func) { eprintln!( "[script] runtime error in {label}: {e}\n preview: {}", script_preview(source) ); }}
struct ScriptExecutionContext<'a> { loader: &'a mut ResourceLoader, base_url: &'a Url, document_origin: &'a Origin, import_map: &'a ImportMap,}
/// Execute a script and then preload modules referenced by literal/// `import("<specifier>")` calls in classic script source.fn execute_source_with_dynamic_import_preloads( vm: &mut Vm, ctx: &mut ScriptExecutionContext<'_>, source: &str, label: &str, kind: ScriptKind,) { let Some(program) = parse_source(source, label, kind) else { return; }; execute_program(vm, &program, source, label);
if kind == ScriptKind::Classic { crate::module_loader::preload_dynamic_imports_for_program( vm, ctx.loader, ctx.document_origin, ctx.base_url, ctx.import_map, &program, ); }}
fn execute_module_source( vm: &mut Vm, loader: &mut ResourceLoader, document_origin: &Origin, import_map: &ImportMap, key: String, base: Url, source: String,) { crate::module_loader::load_and_execute_modules( vm, loader, document_origin, vec![crate::module_loader::EntryModule { key, base, source }], import_map, );}
/// Trim a script to its first ~120 chars of "meaningful" content for error/// logging. Strips leading whitespace, comment fences, and CDATA wrappers/// so the snippet starts with code the reader can recognise.fn script_preview(source: &str) -> String { let trimmed = source.trim_start(); let trimmed = trimmed .strip_prefix("//<![CDATA[") .unwrap_or(trimmed) .trim_start(); let trimmed = trimmed.strip_prefix("<!--").unwrap_or(trimmed).trim_start(); let trimmed: String = trimmed.chars().take(120).collect(); trimmed.replace('\n', " ")}
/// Execute all `<script>` elements on the page.////// Takes ownership of the DOM `Document`, creates a JS VM with DOM access,/// executes scripts in document order (respecting `defer` and `async`/// attributes), then returns the (possibly modified) document.////// Script execution order:/// 1. Synchronous scripts (no `defer`/`async`): execute in document order,/// blocking further script processing./// 2. Deferred scripts (`defer`): execute after all synchronous scripts,/// in document order./// 3. Async scripts (`async`): execute as soon as fetched; for synchronous/// fetching this is equivalent to document order.////// Scripts with an unrecognized `type` attribute are skipped./// Errors in one script do not prevent other scripts from running.pub fn execute_page_scripts( doc: Document, loader: &mut ResourceLoader, base_url: &Url,) -> Document { let document_origin = base_url.origin(); execute_page_scripts_with_origin(doc, loader, base_url, &document_origin)}
/// Execute all `<script>` elements with explicit Same-Origin Policy enforcement.pub fn execute_page_scripts_with_origin( doc: Document, loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin,) -> Document { let mut vm = execute_scripts_into_vm(doc, loader, base_url, document_origin); we_js::fetch::clear_document_origin(); vm.detach_document().unwrap_or_default()}
/// Execute all `<script>` elements and return the VM with the document still/// attached. This allows the caller to fire additional lifecycle events/// (e.g. `load`) after loading subresources.////// The caller MUST call `we_js::fetch::clear_document_origin()` and/// `vm.detach_document()` when done.pub fn execute_scripts_into_vm( doc: Document, loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin,) -> Vm { execute_scripts_into_vm_with_console(doc, loader, base_url, document_origin, None)}
/// Same as [`execute_scripts_into_vm`] but installs `console` on the VM/// before any scripts run, so callers (e.g. the headless test harness) can/// capture `console.log` output emitted by the initial script pass.////// Pass `None` to use the default stdout/stderr console.pub fn execute_scripts_into_vm_with_console( doc: Document, loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin, console: Option<Box<dyn ConsoleOutput>>,) -> Vm { // Find all <script> elements in document order. let mut script_nodes = Vec::new(); let root = doc.root(); collect_script_nodes(&doc, root, &mut script_nodes);
// Extract script info for every <script> in document order, then pick out // the import map (the first `<script type="importmap">`) before filtering // down to executable scripts (classic + module). let all_scripts: Vec<(NodeId, ScriptInfo)> = script_nodes .into_iter() .map(|node| (node, extract_script_info(&doc, node))) .collect();
let import_map = extract_import_map(&all_scripts, base_url);
let scripts: Vec<(NodeId, ScriptInfo)> = all_scripts .into_iter() .filter(|(_, info)| classify_script(&info.type_attr) != ScriptKind::Skip) .collect();
if scripts.is_empty() { // No scripts: still create a VM so lifecycle events can fire. let mut vm = Vm::new(); if let Some(c) = console { vm.set_console_output(c); } vm.attach_document(doc); let origin_str = document_origin.serialize(); we_js::fetch::set_document_origin(&origin_str); vm.set_document_origin(&origin_str); vm.set_document_url(base_url.clone()); install_module_specifier_resolver(&import_map, base_url); we_js::fetch::set_host_fetch_handler(crate::loader::fetch_api_cached_response); load_service_worker_registrations(&mut vm, document_origin); load_cache_api_storage(document_origin, base_url); we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str); // readyState: parsing done → interactive. vm.set_ready_state("interactive"); // Fire DOMContentLoaded (no scripts to run, so fire immediately). we_js::dom_bridge::fire_lifecycle_event( &mut vm, "DOMContentLoaded", we_js::dom_bridge::LifecycleTarget::Document, true, // bubbles false, // not cancelable ); save_service_worker_registrations(&mut vm, document_origin); save_cache_api_storage(document_origin); return vm; }
// Create VM with DOM access. let mut vm = Vm::new(); if let Some(c) = console { vm.set_console_output(c); } vm.attach_document(doc);
// Set the document origin for SOP enforcement in the fetch API and DOM bridge. let origin_str = document_origin.serialize(); we_js::fetch::set_document_origin(&origin_str); vm.set_document_origin(&origin_str);
// Set the document URL so window.location properties work correctly. vm.set_document_url(base_url.clone()); install_module_specifier_resolver(&import_map, base_url); we_js::fetch::set_host_fetch_handler(crate::loader::fetch_api_cached_response); load_service_worker_registrations(&mut vm, document_origin); load_cache_api_storage(document_origin, base_url);
// Initialize the window global object (includes window.location). we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str);
// readyState: HTML is already fully parsed → interactive. vm.set_ready_state("interactive");
// Separate into immediate (sync + async) classic scripts, deferred classic // scripts, and module scripts. Module scripts are always deferred (per the // HTML spec they behave like `defer`) and run after parser-inserted classic // scripts, in document order. let mut deferred_sources: Vec<(String, String)> = Vec::new(); let mut module_entries: Vec<crate::module_loader::EntryModule> = Vec::new(); let mut inline_module_index: usize = 0;
let csp = loader.csp().clone();
for (_node, info) in &scripts { let kind = classify_script(&info.type_attr);
if kind == ScriptKind::Module { // Module scripts are deferred and link through the module graph // loader after all classic parser-inserted scripts run. Each is // recorded with its registry key and the base URL used to resolve // its import specifiers. let entry = if let Some(ref src) = info.src { let module_url = match we_url::Url::parse_with_base(src, base_url) .or_else(|_| we_url::Url::parse(src)) { Ok(u) => u, Err(_) => { eprintln!("[module] invalid src URL: {src}"); continue; } }; match fetch_script_text( loader, src, base_url, document_origin, info.referrer_policy, ) { Some(text) => crate::module_loader::EntryModule { key: module_url.serialize(), base: module_url, source: text, }, None => continue, } } else if let Some(ref text) = info.text { if !csp.allows_inline(DirectiveType::ScriptSrc, text, info.nonce.as_deref()) { eprintln!("[script] blocked by CSP: inline module script"); continue; } let key = format!( "{}#we-inline-module-{inline_module_index}", base_url.serialize() ); inline_module_index += 1; crate::module_loader::EntryModule { key, base: base_url.clone(), source: text.clone(), } } else { continue; }; module_entries.push(entry); continue; }
// Resolve the classic script source text. let (source, label) = if let Some(ref src) = info.src { // External script: fetch it (CSP URL check happens in fetch_subresource). match fetch_script_text(loader, src, base_url, document_origin, info.referrer_policy) { Some(text) => (text, src.clone()), None => continue, } } else if let Some(ref text) = info.text { // Check CSP for inline scripts. if !csp.allows_inline(DirectiveType::ScriptSrc, text, info.nonce.as_deref()) { eprintln!("[script] blocked by CSP: inline script"); continue; } (text.clone(), "<inline>".to_string()) } else { continue; };
if info.defer && !info.async_attr && info.src.is_some() { // Deferred external scripts: queue for later execution. // Per spec, defer only applies to external scripts and is // ignored when async is also present. deferred_sources.push((source, label)); } else { // Synchronous and async scripts: execute immediately. // Async scripts have no ordering guarantee, but since we // fetch synchronously they execute in document order. let mut script_ctx = ScriptExecutionContext { loader, base_url, document_origin, import_map: &import_map, }; execute_source_with_dynamic_import_preloads( &mut vm, &mut script_ctx, &source, &label, ScriptKind::Classic, ); } }
// Module scripts run after classic synchronous scripts, in document order, // before DOMContentLoaded. The loader fetches their transitive import graph // and evaluates dependencies first so exports are published before use. crate::module_loader::load_and_execute_modules( &mut vm, loader, document_origin, module_entries, &import_map, );
// Execute deferred scripts in document order. for (source, label) in &deferred_sources { let mut script_ctx = ScriptExecutionContext { loader, base_url, document_origin, import_map: &import_map, }; execute_source_with_dynamic_import_preloads( &mut vm, &mut script_ctx, source, label, ScriptKind::Classic, ); }
// Fire DOMContentLoaded after parser-inserted module and defer scripts. // Deferred startup bundles commonly register their own DOMContentLoaded // listeners; firing early means those listeners never run. we_js::dom_bridge::fire_lifecycle_event( &mut vm, "DOMContentLoaded", we_js::dom_bridge::LifecycleTarget::Document, true, // bubbles false, // not cancelable );
// Pump the event loop to handle any pending microtasks/timers. let _ = vm.pump_event_loop();
// Drain dynamically-inserted <script> elements. Running scripts (and the // microtasks/timers they schedule) can append <script> nodes to the // document — e.g. loader/bootstrap shims that fetch their next bundle by // injecting a <script src>. Those nodes are recorded by the DOM bridge as // they are inserted; fetch and execute them here, re-pumping between // rounds since each executed script may schedule work that inserts more. drain_dynamic_scripts( &mut vm, loader, base_url, document_origin, &csp, &import_map, );
save_service_worker_registrations(&mut vm, document_origin); save_cache_api_storage(document_origin); vm}
/// Maximum number of fetch-and-execute rounds for dynamically-inserted/// scripts. Each round can queue more scripts; the cap guards against a/// pathological page that inserts a fresh script on every round forever.const MAX_DYNAMIC_SCRIPT_ROUNDS: usize = 64;const MAX_ASYNC_DYNAMIC_SCRIPT_ROUNDS: usize = 8;
/// Pump VM async work and execute any dynamic `<script>` elements appended by/// that work. Hosts call this after lifecycle events such as `load`, where page/// handlers can schedule postload script bundles.pub fn drain_event_loop_and_dynamic_scripts( vm: &mut Vm, loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin,) { let csp = loader.csp().clone(); let import_map = vm .borrow_document() .map(|doc| { let mut script_nodes = Vec::new(); collect_script_nodes(&doc, doc.root(), &mut script_nodes); let all_scripts: Vec<(NodeId, ScriptInfo)> = script_nodes .into_iter() .map(|node| (node, extract_script_info(&doc, node))) .collect(); extract_import_map(&all_scripts, base_url) }) .unwrap_or_default(); install_module_specifier_resolver(&import_map, base_url); for _ in 0..MAX_ASYNC_DYNAMIC_SCRIPT_ROUNDS { let _ = vm.run_event_loop(25); if we_js::dom_bridge::has_pending_dynamic_scripts() { drain_dynamic_scripts(vm, loader, base_url, document_origin, &csp, &import_map); } else if !vm.has_pending_event_loop_work() { return; } } if we_js::dom_bridge::has_pending_dynamic_scripts() { eprintln!( "[script] async dynamic <script> drain exceeded {MAX_ASYNC_DYNAMIC_SCRIPT_ROUNDS} rounds; \ remaining inserts ignored" ); let _ = we_js::dom_bridge::take_pending_dynamic_scripts(); }}
/// Fetch and execute `<script>` elements inserted into the document by/// running script, looping until the queue drains or the round cap is hit.fn drain_dynamic_scripts( vm: &mut Vm, loader: &mut ResourceLoader, base_url: &Url, document_origin: &Origin, csp: &crate::csp::PolicyList, import_map: &ImportMap,) { for _ in 0..MAX_DYNAMIC_SCRIPT_ROUNDS { let pending = we_js::dom_bridge::take_pending_dynamic_scripts(); if pending.is_empty() { return; } for script in pending { let kind = classify_script(&script.type_attr); if kind == ScriptKind::Skip { continue; } let mut script_url = None; let (source, label) = if let Some(src) = script.src { script_url = resolve_script_url(&src, base_url).ok(); let policy = script .referrer_policy .as_deref() .and_then(ReferrerPolicy::parse); match fetch_script_text(loader, &src, base_url, document_origin, policy) { Some(text) => (text, src), None => { we_js::dom_bridge::fire_event_at_node( vm, script.node, "error", false, false, ); continue; } } } else if let Some(text) = script.text { if !csp.allows_inline(DirectiveType::ScriptSrc, &text, script.nonce.as_deref()) { eprintln!("[script] blocked by CSP: dynamic inline script"); we_js::dom_bridge::fire_event_at_node(vm, script.node, "error", false, false); continue; } (text, "<dynamic-inline>".to_string()) } else { continue; };
if kind == ScriptKind::Module { let (key, module_base) = match script_url { Some(url) => (url.serialize(), url), None => ( format!( "{}#we-dynamic-module-{}", base_url.serialize(), script.node.index() ), base_url.clone(), ), }; execute_module_source( vm, loader, document_origin, import_map, key, module_base, source, ); } else { let mut script_ctx = ScriptExecutionContext { loader, base_url, document_origin, import_map, }; execute_source_with_dynamic_import_preloads( vm, &mut script_ctx, &source, &label, kind, ); } we_js::dom_bridge::fire_event_at_node(vm, script.node, "load", false, false); } // Each executed script may have scheduled microtasks/timers that // insert further scripts; settle them before the next round. let _ = vm.pump_event_loop(); } if we_js::dom_bridge::has_pending_dynamic_scripts() { eprintln!( "[script] dynamic <script> drain exceeded {MAX_DYNAMIC_SCRIPT_ROUNDS} rounds; \ remaining inserts ignored" ); let _ = we_js::dom_bridge::take_pending_dynamic_scripts(); }}
fn load_service_worker_registrations(vm: &mut Vm, origin: &Origin) { // Hook script bytes through the loader cache so register() captures // real bytes for the routing layer to evaluate later. Without this, // registrations end up with a URL-string placeholder that the worker // thread cannot run. crate::service_worker_router::install_loader_cache_script_resolver(); let store = crate::service_worker::ServiceWorkerManager::new().load(origin); vm.set_service_worker_registrations(store);}
fn save_service_worker_registrations(vm: &mut Vm, origin: &Origin) { crate::service_worker_router::clear_script_resolver(); if let Some(store) = vm.take_service_worker_registrations() { crate::service_worker::ServiceWorkerManager::new().save(origin, &store); vm.set_service_worker_registrations(store); }}
fn load_cache_api_storage(origin: &Origin, base_url: &Url) { let storage = crate::cache_storage::CacheManager::new().load(origin); we_js::cache_api::install_cache_storage(storage); we_js::cache_api::set_cache_origin(origin.clone()); we_js::cache_api::set_cache_base_url(base_url.clone());}
fn save_cache_api_storage(origin: &Origin) { let storage = we_js::cache_api::snapshot_cache_storage(); crate::cache_storage::CacheManager::new().save(origin, &storage);}
#[cfg(test)]mod tests { use super::*; use we_html::parse_html;
/// Helper: parse HTML, execute scripts, return the document. fn run_scripts(html: &str) -> Document { let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("about:blank").unwrap(); execute_page_scripts(doc, &mut loader, &base_url) }
#[test] fn test_no_scripts() { let doc = run_scripts("<html><body><p>Hello</p></body></html>"); // Should return the document unchanged. assert!(doc.tag_name(doc.root()).is_none()); // root is Document node }
#[test] fn test_inline_script_executes() { // Script that modifies a DOM element's text content. let html = r#"<html><body> <div id="target">before</div> <script> var el = document.getElementById("target"); el.textContent = "after"; </script> </body></html>"#; let doc = run_scripts(html);
// Find the div and check its text content was modified. let mut found = false; fn find_div(doc: &Document, node: NodeId, found: &mut bool) { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("target") { // Check child text node. for child in doc.children(node) { if let Some(text) = doc.text_content(child) { if text == "after" { *found = true; } } } } } for child in doc.children(node) { find_div(doc, child, found); } } find_div(&doc, doc.root(), &mut found); assert!(found, "script should have modified div text to 'after'"); }
#[test] fn test_multiple_scripts_share_scope() { let html = r#"<html><body> <script>var x = 42;</script> <script>var y = x + 1;</script> <div id="result">placeholder</div> <script> document.getElementById("result").textContent = String(y); </script> </body></html>"#; let doc = run_scripts(html);
fn find_result(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("result") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_result(doc, child) { return Some(result); } } None }
let result = find_result(&doc, doc.root()); assert_eq!(result.as_deref(), Some("43")); }
#[test] fn test_unknown_type_not_executed() { let html = r#"<html><body> <div id="target">original</div> <script type="text/template"> document.getElementById("target").textContent = "changed"; </script> </body></html>"#; let doc = run_scripts(html);
fn find_target(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("target") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_target(doc, child) { return Some(result); } } None }
let result = find_target(&doc, doc.root()); assert_eq!(result.as_deref(), Some("original")); }
#[test] fn test_type_text_javascript_executes() { let html = r#"<html><body> <div id="target">before</div> <script type="text/javascript"> document.getElementById("target").textContent = "after"; </script> </body></html>"#; let doc = run_scripts(html);
fn find_target(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("target") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_target(doc, child) { return Some(result); } } None }
let result = find_target(&doc, doc.root()); assert_eq!(result.as_deref(), Some("after")); }
#[test] fn test_script_error_does_not_crash() { // A script with a runtime error should not prevent subsequent scripts. let html = r#"<html><body> <div id="target">before</div> <script> undefinedFunction(); </script> <script> document.getElementById("target").textContent = "after"; </script> </body></html>"#; let doc = run_scripts(html);
fn find_target(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("target") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_target(doc, child) { return Some(result); } } None }
let result = find_target(&doc, doc.root()); assert_eq!(result.as_deref(), Some("after")); }
#[test] fn test_empty_script_no_crash() { let doc = run_scripts("<html><body><script></script></body></html>"); assert!(!doc.is_empty()); }
#[test] fn test_defer_scripts_run_after_sync() { // In our parse-first model, both sync and defer run after parsing. // defer scripts with src are queued; defer inline scripts are treated // as sync. Without external script loading in tests, we verify that // defer inline scripts still execute (treated as sync per spec — // defer only applies to external scripts). let html = r#"<html><body> <div id="result">0</div> <script defer> var counter = 1; </script> <script> document.getElementById("result").textContent = String(counter); </script> </body></html>"#; let doc = run_scripts(html);
fn find_result(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("result") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_result(doc, child) { return Some(result); } } None }
let result = find_result(&doc, doc.root()); // defer on inline scripts is ignored per spec, so counter=1 runs first assert_eq!(result.as_deref(), Some("1")); }
#[test] fn external_defer_script_can_observe_dom_content_loaded() { crate::loader::clear_service_worker_caches(); crate::loader::set_network_offline(true);
let script_url = Url::parse("https://example.test/deferred.js").unwrap(); crate::loader::put_service_worker_cache_response(crate::loader::CachedResponse::new( "test", &script_url, "text/javascript", br#" document.addEventListener("DOMContentLoaded", function() { document.getElementById("target").textContent = document.readyState; }); "# .to_vec(), ));
let html = r#"<html><body> <div id="target">pending</div> <script defer src="/deferred.js"></script> </body></html>"#; let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("https://example.test/page.html").unwrap(); let doc = execute_page_scripts(doc, &mut loader, &base_url);
crate::loader::set_network_offline(false); crate::loader::clear_service_worker_caches();
assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("interactive"), "external defer scripts must run before DOMContentLoaded fires" ); }
#[test] fn test_script_create_element() { let html = r#"<html><body> <div id="container"></div> <script> var div = document.createElement("p"); div.textContent = "dynamic"; document.getElementById("container").appendChild(div); </script> </body></html>"#; let doc = run_scripts(html);
// Check that a <p> was added inside #container. fn find_dynamic(doc: &Document, node: NodeId) -> bool { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("container") { for child in doc.children(node) { if doc.tag_name(child) == Some("p") { return true; } } } } for child in doc.children(node) { if find_dynamic(doc, child) { return true; } } false }
assert!( find_dynamic(&doc, doc.root()), "script should have appended a <p> to #container" ); }
/// Find the text content of the element with the given id. fn find_text_by_id(doc: &Document, node: NodeId, id: &str) -> Option<String> { if doc.get_attribute(node, "id") == Some(id) { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } return Some(String::new()); } for child in doc.children(node) { if let Some(r) = find_text_by_id(doc, child, id) { return Some(r); } } None }
#[test] fn fetch_api_uses_offline_loader_cache() { crate::loader::clear_service_worker_caches(); crate::loader::set_network_offline(true);
let api_url = Url::parse("https://example.test/data.json").unwrap(); crate::loader::put_service_worker_cache_response(crate::loader::CachedResponse::new( "test", &api_url, "application/json", br#"{"answer":"cached"}"#.to_vec(), ));
let html = r#"<html><body> <div id="target">pending</div> <script> fetch("/data.json").then(function(resp) { return resp.json(); }).then(function(data) { document.getElementById("target").textContent = data.answer; }, function(err) { document.getElementById("target").textContent = "error"; }); </script> </body></html>"#; let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("https://example.test/page.html").unwrap(); let origin = base_url.origin(); let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin); vm.run_event_loop(25).expect("event loop failed");
let doc = vm.detach_document().unwrap_or_default(); we_js::fetch::clear_document_origin(); crate::loader::set_network_offline(false); crate::loader::clear_service_worker_caches();
assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("cached"), "page fetch() should resolve from cache_put-style offline entries" ); }
#[test] fn dynamically_inserted_inline_script_executes() { // A running script inserts a fresh <script> whose inline body mutates // the DOM. The inserted script must be fetched/executed by the // dynamic-script drain rather than silently ignored. let html = r#"<html><body> <div id="target"></div> <script> var s = document.createElement("script"); s.textContent = "document.getElementById('target').textContent = 'injected';"; document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("injected"), "dynamically inserted inline <script> should have executed" ); }
#[test] fn dynamically_inserted_script_text_property_executes() { // HTMLScriptElement.text is the legacy alias Bing's RMS loader uses // when it turns inert text/rms blocks into executable scripts. let html = r#"<html><body> <div id="target"></div> <script> var s = document.createElement("script"); s.text = "document.getElementById('target').textContent = 'script-text';"; document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("script-text"), "script.text should populate the inline script body before append" ); }
#[test] fn dynamically_inserted_uppercase_script_src_executes() { // Bing's RMS loader creates `SCRIPT` elements and assigns `src` as a // property before appending. `createElement` should normalize the // internal tag name while preserving the public uppercase `tagName`. let html = r#"<html><body> <div id="target"></div> <script> var s = document.createElement("SCRIPT"); s.src = "data:text/javascript,document.getElementById(%22target%22).textContent=%22external%22;"; document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("external"), "uppercase dynamic script elements with src properties should execute" ); }
#[test] fn dynamic_script_load_handler_can_queue_next_script() { // RMS-style loaders wait for a dynamic script's `load` handler before // appending the dependent bundle. let html = r#"<html><body> <div id="first"></div> <div id="second"></div> <script> var s = document.createElement("SCRIPT"); s.onload = function() { var next = document.createElement("script"); next.text = "document.getElementById('second').textContent = 'two';"; document.body.appendChild(next); }; s.src = "data:text/javascript,document.getElementById(%22first%22).textContent=%22one%22;"; document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "first").as_deref(), Some("one"), "first dynamic external script should execute" ); assert_eq!( find_text_by_id(&doc, doc.root(), "second").as_deref(), Some("two"), "dynamic script load handler should be able to append another executable script" ); }
#[test] fn image_decode_promise_resolves_for_loaded_image() { // The Bing homepage image bootstrap waits on `new Image().decode()` // before inserting the background-image style. let html = r#"<html><body> <div id="target"></div> <script> var img = new Image(); img.src = "data:image/png;base64,iVBORw0KGgo="; img.decode().then(function() { document.getElementById("target").textContent = "decoded"; }); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("decoded"), "Image.decode should resolve and drain through the page event loop" ); }
#[test] fn image_src_assignment_fires_load_or_error() { // Bing probes AVIF support through Image.onerror before decoding the // selected homepage image. let html = r#"<html><body> <div id="target"></div> <script> var supported = new Image(); supported.onload = function() { document.getElementById("target").textContent += "load"; }; supported.src = "data:image/png;base64,iVBORw0KGgo="; var unsupported = new Image(); unsupported.onerror = function() { document.getElementById("target").textContent += ":error"; }; unsupported.src = "data:image/avif;base64,AAAA"; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("load:error"), "Image src assignment should fire asynchronous load/error callbacks" ); }
#[test] fn load_event_inserted_script_executes_after_host_drain() { // Bing's postload RMS path appends scripts from load/timer callbacks, // after the initial parser-inserted script pass has already drained. let html = r#"<html><body> <div id="target"></div> <script> window.onload = function() { setTimeout(function() { var s = document.createElement("script"); s.text = "document.getElementById('target').textContent = 'postload';"; document.body.appendChild(s); }, 0); }; </script> </body></html>"#; let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("about:blank").unwrap(); let origin = base_url.origin(); let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin);
vm.set_ready_state("complete"); we_js::dom_bridge::fire_lifecycle_event( &mut vm, "load", we_js::dom_bridge::LifecycleTarget::Window, false, false, ); drain_event_loop_and_dynamic_scripts(&mut vm, &mut loader, &base_url, &origin);
let doc = vm.detach_document().unwrap_or_default(); we_js::fetch::clear_document_origin(); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("postload"), "scripts inserted by load/timer callbacks should execute" ); }
#[test] fn delayed_load_event_script_executes_after_host_drain() { // The host drain must not return just because no script has been // queued yet; pending timers can append scripts on a later turn. let html = r#"<html><body> <div id="target"></div> <script> window.onload = function() { setTimeout(function() { var s = document.createElement("script"); s.textContent = "document.getElementById('target').textContent = 'delayed-postload';"; document.body.appendChild(s); }, 50); }; </script> </body></html>"#; let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("about:blank").unwrap(); let origin = base_url.origin(); let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin);
vm.set_ready_state("complete"); we_js::dom_bridge::fire_lifecycle_event( &mut vm, "load", we_js::dom_bridge::LifecycleTarget::Window, false, false, ); drain_event_loop_and_dynamic_scripts(&mut vm, &mut loader, &base_url, &origin);
let doc = vm.detach_document().unwrap_or_default(); we_js::fetch::clear_document_origin(); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("delayed-postload"), "scripts inserted by delayed load/timer callbacks should execute" ); }
#[test] fn dynamically_inserted_scripts_chain() { // The first inserted script inserts a second one; both must run, // exercising the multi-round drain loop. let html = r#"<html><body> <div id="a"></div> <div id="b"></div> <script> var s1 = document.createElement("script"); s1.textContent = "document.getElementById('a').textContent = 'one';" + "var s2 = document.createElement('script');" + "s2.textContent = \"document.getElementById('b').textContent = 'two';\";" + "document.body.appendChild(s2);"; document.body.appendChild(s1); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "a").as_deref(), Some("one"), "first dynamically inserted script should have run" ); assert_eq!( find_text_by_id(&doc, doc.root(), "b").as_deref(), Some("two"), "script inserted by another dynamic script should also have run" ); }
#[test] fn unattached_inline_script_does_not_execute() { // A <script> appended to a detached subtree is not connected to the // document and must not run. let html = r#"<html><body> <div id="target">orig</div> <script> var s = document.createElement("script"); s.textContent = "document.getElementById('target').textContent = 'ran';"; var holder = document.createElement("div"); holder.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("orig"), "script in a detached subtree must not execute" ); }
/// Helper: parse HTML, execute scripts with VM, return the VM. fn run_scripts_vm(html: &str) -> Vm { let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("about:blank").unwrap(); let origin = base_url.origin(); execute_scripts_into_vm(doc, &mut loader, &base_url, &origin) }
#[test] fn test_dom_content_loaded_fires() { // DOMContentLoaded should fire during script execution. // A sync script registers a listener, then DOMContentLoaded fires // after sync scripts, recording the event. let html = r#"<html><body> <div id="result">none</div> <script> document.addEventListener("DOMContentLoaded", function() { document.getElementById("result").textContent = "fired"; }); </script> </body></html>"#; let mut vm = run_scripts_vm(html); let doc = vm.detach_document().unwrap();
fn find_result(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("result") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_result(doc, child) { return Some(result); } } None }
let result = find_result(&doc, doc.root()); assert_eq!(result.as_deref(), Some("fired")); }
#[test] fn test_ready_state_interactive_during_scripts() { // readyState should be "interactive" when scripts run. let html = r#"<html><body> <div id="result">none</div> <script> document.getElementById("result").textContent = document.readyState; </script> </body></html>"#; let mut vm = run_scripts_vm(html); let doc = vm.detach_document().unwrap();
fn find_result(doc: &Document, node: NodeId) -> Option<String> { if doc.tag_name(node) == Some("div") { if doc.get_attribute(node, "id") == Some("result") { for child in doc.children(node) { if let Some(text) = doc.text_content(child) { return Some(text.to_string()); } } } } for child in doc.children(node) { if let Some(result) = find_result(doc, child) { return Some(result); } } None }
let result = find_result(&doc, doc.root()); assert_eq!(result.as_deref(), Some("interactive")); }
#[test] fn test_no_scripts_still_fires_dcl() { // Even pages with no scripts should get DOMContentLoaded fired. // The VM is created anyway for lifecycle support. let html = r#"<html><body><p>No scripts</p></body></html>"#; let mut vm = run_scripts_vm(html); // Verify the VM has a document and readyState is "interactive". assert_eq!(vm.ready_state(), "interactive"); let _ = vm.detach_document(); }
#[test] fn classify_script_distinguishes_kinds() { assert_eq!(classify_script(&None), ScriptKind::Classic); assert_eq!(classify_script(&Some("".into())), ScriptKind::Classic); assert_eq!( classify_script(&Some("text/javascript".into())), ScriptKind::Classic ); assert_eq!(classify_script(&Some("module".into())), ScriptKind::Module); assert_eq!( classify_script(&Some(" Module ".into())), ScriptKind::Module ); assert_eq!(classify_script(&Some("importmap".into())), ScriptKind::Skip); assert_eq!( classify_script(&Some("application/json".into())), ScriptKind::Skip ); assert_eq!( classify_script(&Some("text/template".into())), ScriptKind::Skip ); }
#[test] fn module_script_executes() { // An inline `<script type="module">` was previously skipped because // "module" is not a classic JavaScript MIME type. It must now run via // the module parser. let html = r#"<html><body> <div id="target">before</div> <script type="module"> document.getElementById("target").textContent = "after"; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("after"), "module script should have modified the target text" ); }
#[test] fn module_script_with_export_executes() { // A module containing `export` is valid only under the module parser // (the classic parser rejects top-level `export`). The non-export // side effects must still run. let html = r#"<html><body> <div id="target">before</div> <script type="module"> export const greeting = "after"; document.getElementById("target").textContent = greeting; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("after"), "module script with an export should still execute its body" ); }
#[test] fn module_scripts_run_after_classic_scripts() { // Module scripts are deferred: they run after classic parser-inserted // scripts regardless of document order. let html = r#"<html><body> <div id="target">start</div> <script type="module"> document.getElementById("target").textContent += "-module"; </script> <script> document.getElementById("target").textContent += "-classic"; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("start-classic-module"), "classic script should run before the deferred module script" ); }
#[test] fn importmap_script_is_not_executed() { // `<script type="importmap">` is a data block, not script: it must not // be executed as JavaScript (which would throw on the JSON body). let html = r#"<html><body> <div id="target">unchanged</div> <script type="importmap"> { "imports": { "x": "/x.js" } } </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("unchanged"), "an importmap block must not execute as a classic script" ); }
#[test] fn dynamically_inserted_module_script_executes() { // A module script inserted at runtime must be parsed with the module // parser by the dynamic-script drain. let html = r#"<html><body> <div id="target"></div> <script> var s = document.createElement("script"); s.setAttribute("type", "module"); s.textContent = "export const v = 'injected';" + "document.getElementById('target').textContent = v;"; document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("injected"), "dynamically inserted module script should run via the module parser" ); }
#[test] fn dynamically_inserted_external_module_script_links_imports() { // es-module-shims feature detection appends a type=module data script // whose body imports another generated module. The dynamic-script drain // must use the module graph loader so those static imports are linked. let html = r#"<html><body> <div id="target"></div> <script> var dep = URL.createObjectURL(new Blob([ "export const greeting = 'linked';" ], { type: "text/javascript" })); var body = "import * as m from '" + dep + "';" + "self._esmsi = m;" + "document.getElementById('target').textContent = m.greeting;"; var s = document.createElement("script"); s.setAttribute("type", "module"); s.src = URL.createObjectURL(new Blob([body], { type: "text/javascript" })); document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("linked"), "dynamically inserted external modules should link their imports" ); }
#[test] fn dynamically_inserted_module_script_object_assign_properties_execute() { // es-module-shims creates module scripts with // `Object.assign(document.createElement("script"), { type, src })`. // Object.assign must reflect those DOM properties before appendChild // queues the script for host execution. let html = r#"<html><body> <div id="target"></div> <script> var dep = URL.createObjectURL(new Blob([ "export const greeting = 'assigned';" ], { type: "text/javascript" })); var body = "import { greeting } from '" + dep + "';" + "document.getElementById('target').textContent = greeting;"; var s = Object.assign(document.createElement("script"), { type: "module", src: URL.createObjectURL(new Blob([body], { type: "text/javascript" })) }); document.body.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("assigned"), "Object.assign-reflected module script properties should execute" ); }
#[test] fn dynamically_inserted_module_script_load_event_observes_namespace_assignment() { // es-module-shims resolves its dynamic-import polyfill from the // dynamically inserted module script's load event after the module body // assigns a namespace object to `self._esmsi`. let html = r#"<html><body> <div id="target"></div> <script> var dep = URL.createObjectURL(new Blob([ "export const greeting = 'loaded';" ], { type: "text/javascript" })); var body = "import * as m from '" + dep + "';" + "self._esmsi = m;"; var s = document.createElement("script"); s.setAttribute("type", "module"); s.src = URL.createObjectURL(new Blob([body], { type: "text/javascript" })); s.addEventListener("load", function() { document.getElementById("target").textContent = self._esmsi && self._esmsi.greeting; }); document.head.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("loaded"), "dynamic module load events should fire after namespace assignment" ); }
#[test] fn post_load_dynamic_module_static_import_uses_import_map() { // Dynamic module scripts inserted from load/timer callbacks still need // the document import map when the module graph loader resolves their // static imports. let html = r#"<html><head> <script type="importmap"> { "imports": { "post-load-greeter": "data:text/javascript,export const greeting = 'mapped postload';" } } </script> <script> window.onload = function() { setTimeout(function() { var s = document.createElement("script"); s.type = "module"; s.textContent = "import { greeting } from 'post-load-greeter';" + "document.getElementById('target').textContent = greeting;"; document.body.appendChild(s); }, 10); }; </script> </head><body> <div id="target">before</div> </body></html>"#; let doc = parse_html(html); let mut loader = ResourceLoader::new(); let base_url = Url::parse("https://example.test/page").unwrap(); let origin = base_url.origin(); let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin);
vm.set_ready_state("complete"); we_js::dom_bridge::fire_lifecycle_event( &mut vm, "load", we_js::dom_bridge::LifecycleTarget::Window, false, false, ); drain_event_loop_and_dynamic_scripts(&mut vm, &mut loader, &base_url, &origin);
let doc = vm.detach_document().unwrap_or_default(); we_js::fetch::clear_document_origin(); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("mapped postload"), "post-load dynamic module imports should resolve through the document import map" ); }
#[test] fn dynamic_module_load_event_microtasks_are_pumped() { // es-module-shims chains Promise feature detection from a dynamic // module script's load event. The host event-loop pump must run those // microtasks before deciding dynamic script processing is complete. let html = r#"<html><body> <div id="target"></div> <script> var dep = URL.createObjectURL(new Blob([ "export const value = 7;" ], { type: "text/javascript" })); var body = "import * as m from '" + dep + "';" + "self._esmsi = m;"; var s = document.createElement("script"); s.setAttribute("type", "module"); s.src = URL.createObjectURL(new Blob([body], { type: "text/javascript" })); s.addEventListener("load", function() { Promise.resolve().then(function() { return Promise.all([ Promise.resolve(self._esmsi.value), false, true ]); }).then(function(values) { document.getElementById("target").textContent = values[0] + ":" + values[1] + ":" + values[2]; }); }); document.head.appendChild(s); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("7:false:true"), "microtasks queued by dynamic module load events should be pumped" ); }
#[test] fn module_imports_link_across_files() { // An inline module imports a binding from a `data:` module (which loads // offline). The loader must fetch the dependency, evaluate it first, // and link the import so the inline module can use the exported value. let html = r#"<html><body> <div id="target">before</div> <script type="module"> import { greeting } from "data:text/javascript,export const greeting = 'linked';"; document.getElementById("target").textContent = greeting; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("linked"), "module import from a sibling module should be linked and usable" ); }
#[test] fn module_imports_bare_specifier_via_import_map() { // A `<script type="importmap">` maps a bare specifier to an address. // The module loader must consult the map to resolve the import (which // would otherwise be unresolvable) and link the exported binding. let html = r#"<html><body> <div id="target">before</div> <script type="importmap"> { "imports": { "greeter": "data:text/javascript,export const greeting = 'mapped';" } } </script> <script type="module"> import { greeting } from "greeter"; document.getElementById("target").textContent = greeting; </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("mapped"), "bare specifier should resolve through the import map" ); }
#[test] fn module_imports_function_from_dependency() { // The dependency exports a function; the importing module must be able // to call it after linkage. let html = r#"<html><body> <div id="target">before</div> <script type="module"> import { shout } from "data:text/javascript,export function shout(s) { return s + '!'; }"; document.getElementById("target").textContent = shout("hi"); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("hi!"), "imported function should be callable through module linkage" ); }
#[test] fn classic_script_dynamic_import_literal_resolves() { // Bing's homepage bootstrap calls dynamic import() from a classic // inline script. Literal targets should be preloaded into the module // registry before the event loop settles the returned Promise. let html = r#"<html><body> <div id="target">before</div> <script> import("data:text/javascript,export const greeting = 'dynamic';") .then(function(ns) { document.getElementById("target").textContent = ns.greeting; }, function() { document.getElementById("target").textContent = "failed"; }); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("dynamic"), "classic-script dynamic import should resolve from the preloaded module registry" ); }
#[test] fn classic_script_dynamic_import_bare_specifier_uses_import_map() { // es-module-shims ultimately delegates supported dynamic imports to the // native import() path. When the requested specifier is bare, the // runtime resolver must use the same document import map as the module // graph preloader, or the preloaded registry key will not match. let html = r#"<html><body> <div id="target">before</div> <script type="importmap"> { "imports": { "greeter": "data:text/javascript,export const greeting = 'mapped dynamic';" } } </script> <script> import("greeter").then(function(ns) { document.getElementById("target").textContent = ns.greeting; }, function() { document.getElementById("target").textContent = "failed"; }); </script> </body></html>"#; let doc = run_scripts(html); assert_eq!( find_text_by_id(&doc, doc.root(), "target").as_deref(), Some("mapped dynamic"), "dynamic import should resolve bare specifiers through the import map" ); }}