diff --git a/.isu/issues.json b/.isu/issues.json index 665d26d..656aa3e 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 350, + "next_id": 351, "issues": [ { "id": 1, @@ -4269,6 +4269,19 @@ "author": "piefev", "state": "closed", "created_at": "2026-06-04T19:59:45Z" + }, + { + "id": 350, + "repo": "we", + "title": "Bing SpeechSDK bundle still aborts after export accessor fixes", + "body": "Follow-up from isu issue 280. This change gets bing.com past the earlier `PlatformEvent`/CommonJS accessor failures by adding offline `fetch()` cache handling, accessor descriptors, nested function self-reference capture, and `TypedArray.set`, but the scenario still cannot remove `# xfail`.\n\nRepro:\n`cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/bing.com.we --out-dir crates/e2e/artifacts`\n\nCurrent remaining runtime failures from the repro:\n- `https://r.bing.com/rp/Kztfii5jt_tZqOfH9TXTthmg_8w.js`: `TypeError: undefined is not a function` at line 1 col 10298, near the UUID/default-export helper path `(0,n.default)(\"v3\",48,i.default)`.\n- `https://r.bing.com/rp/t6Bkpkzq5ckAnvfW2PY7kNOTPJ0.js`: `TypeError: undefined is not a function` at line 1 col 287.\n- `https://r.bing.com/rp/8IAmQsuV9JqR_a0NVlBmchPNiAk.js`: `TypeError: undefined is not a function` at line 1 col 111.\n- Timer callbacks also surface `Error: [object Object]` and `TypeError: undefined is not a function`.\n\nScreenshot parity is still failing, with known snapshot/golden drift tracked in isu issue 305 and hydration snapshot work tracked in isu issue 334:\n- desktop: `2.54% match (1197303/1228500 px differ, tol=4, max_diff=0.1000%)`\n- mobile: `5.37% match (311470/329160 px differ, tol=4, max_diff=0.1000%)`\n\nArtifacts from the repro:\n- `crates/e2e/artifacts/real-web/bing.com/desktop.png`\n- `crates/e2e/artifacts/real-web/bing.com/desktop.png.diff.png`\n- `crates/e2e/artifacts/real-web/bing.com/desktop_dom.txt`\n- `crates/e2e/artifacts/real-web/bing.com/desktop_console.txt`\n- `crates/e2e/artifacts/real-web/bing.com/mobile.png`\n- `crates/e2e/artifacts/real-web/bing.com/mobile.png.diff.png`\n- `crates/e2e/artifacts/real-web/bing.com/mobile_dom.txt`\n- `crates/e2e/artifacts/real-web/bing.com/mobile_console.txt`\n\nExpected: continue reducing the Bing JS runtime gaps until the offline hydrated page can run without these bundle aborts; keep isu issue 280 open until `crates/e2e/scenarios/real-web/bing.com.we` passes and its `# xfail` marker is removed.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-06-04T21:22:27Z" } ] } diff --git a/crates/browser/src/loader.rs b/crates/browser/src/loader.rs index f179205..6c6e7b8 100644 --- a/crates/browser/src/loader.rs +++ b/crates/browser/src/loader.rs @@ -220,6 +220,166 @@ fn cached_to_http_response(cached: CachedResponse) -> HttpResponse { } } +/// Satisfy a JavaScript `fetch()` request from the deterministic offline cache. +/// +/// The JS engine owns the Fetch API, so it exposes a host hook instead of +/// depending on this crate directly. We only intercept while offline: normal +/// browsing should continue through the network path unless a service worker +/// explicitly handles the request. +pub fn fetch_api_cached_response( + request: &we_js::fetch::HostFetchRequest, +) -> Option> { + if !network_offline() { + return None; + } + + let url = match Url::parse(&request.url) { + Ok(url) => url, + Err(e) => return Some(Err(format!("Invalid URL: {e}"))), + }; + let method = match fetch_api_method(&request.method) { + Ok(method) => method, + Err(err) => return Some(Err(err)), + }; + if !matches!(method, Method::Get | Method::Head) { + return Some(Err(format!( + "Network error: offline and no cached response for {}", + request.url + ))); + } + + let Some(cached) = cached_response_for(&url) else { + return Some(Err(format!( + "Network error: offline and no cached response for {}", + request.url + ))); + }; + + Some(fetch_result_from_cached_response( + request, &url, method, cached, + )) +} + +fn fetch_api_method(method: &str) -> Result { + match method { + "GET" => Ok(Method::Get), + "POST" => Ok(Method::Post), + "PUT" => Ok(Method::Put), + "DELETE" => Ok(Method::Delete), + "HEAD" => Ok(Method::Head), + "OPTIONS" => Ok(Method::Options), + "PATCH" => Ok(Method::Patch), + other => Err(format!("Unsupported HTTP method: {other}")), + } +} + +fn fetch_api_credentials_mode(mode: &str) -> CredentialsMode { + match mode { + "include" => CredentialsMode::Include, + "omit" => CredentialsMode::Omit, + _ => CredentialsMode::SameOrigin, + } +} + +fn fetch_result_from_cached_response( + request: &we_js::fetch::HostFetchRequest, + url: &Url, + method: Method, + cached: CachedResponse, +) -> Result { + if fetch_api_is_cross_origin(request, url) && request.cors_mode == "no-cors" { + return Ok(we_js::fetch::FetchResult { + status: 0, + status_text: String::new(), + headers: Vec::new(), + body: Vec::new(), + body_stream_id: None, + url: url.serialize(), + redirected: false, + }); + } + + let mut response_headers = Headers::new(); + for (name, value) in &cached.headers { + response_headers.add(name, value); + } + + let headers = fetch_api_visible_headers(request, url, method, &response_headers)?; + let final_url = cached.final_url.clone().unwrap_or_else(|| url.serialize()); + Ok(we_js::fetch::FetchResult { + status: cached.status, + status_text: cached.reason, + headers, + body: if method == Method::Head { + Vec::new() + } else { + cached.body + }, + body_stream_id: None, + url: final_url, + redirected: cached.redirected, + }) +} + +fn fetch_api_visible_headers( + request: &we_js::fetch::HostFetchRequest, + url: &Url, + method: Method, + response_headers: &Headers, +) -> Result, String> { + if !fetch_api_is_cross_origin(request, url) { + return Ok(response_headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect()); + } + + if request.cors_mode != "cors" { + return Err(format!( + "Cross-origin request blocked: {} (mode is '{}')", + request.url, request.cors_mode + )); + } + + let Some(doc_origin) = &request.document_origin else { + return Ok(Vec::new()); + }; + let doc_parsed_origin = Url::parse(&format!("{doc_origin}/")) + .map(|u| u.origin()) + .unwrap_or(Origin::Opaque); + let mut request_headers = Headers::new(); + for (name, value) in &request.headers { + request_headers.add(name, value); + } + if needs_preflight(method, &request_headers) { + return Err(format!( + "CORS preflight failed: offline for {}", + request.url + )); + } + + let credentials_mode = fetch_api_credentials_mode(&request.credentials_mode); + let exposed = check_cors_response(response_headers, &doc_parsed_origin, credentials_mode) + .map_err(|reason| format!("Cross-origin request blocked: {reason}"))?; + let filtered = cors::filter_response_headers(response_headers, &exposed); + Ok(filtered + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect()) +} + +fn fetch_api_is_cross_origin(request: &we_js::fetch::HostFetchRequest, url: &Url) -> bool { + if let Some(doc_origin) = &request.document_origin { + let resource_origin = url.origin(); + let doc_parsed_origin = Url::parse(&format!("{doc_origin}/")) + .map(|u| u.origin()) + .unwrap_or(Origin::Opaque); + !doc_parsed_origin.same_origin(&resource_origin) + } else { + false + } +} + // --------------------------------------------------------------------------- // ResourceLoader // --------------------------------------------------------------------------- diff --git a/crates/browser/src/script_loader.rs b/crates/browser/src/script_loader.rs index 751acd8..79c0ffe 100644 --- a/crates/browser/src/script_loader.rs +++ b/crates/browser/src/script_loader.rs @@ -343,6 +343,7 @@ pub fn execute_scripts_into_vm_with_console( we_js::fetch::set_document_origin(&origin_str); vm.set_document_origin(&origin_str); vm.set_document_url(base_url.clone()); + 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); @@ -375,6 +376,7 @@ pub fn execute_scripts_into_vm_with_console( // Set the document URL so window.location properties work correctly. vm.set_document_url(base_url.clone()); + 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); @@ -936,6 +938,50 @@ mod tests { 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#" +
pending
+ + "#; + 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