diff --git a/crates/obscura-cdp/tests/dynamic_stylesheet_onload_fires.rs b/crates/obscura-cdp/tests/dynamic_stylesheet_onload_fires.rs
new file mode 100644
index 0000000..5150508
--- /dev/null
+++ b/crates/obscura-cdp/tests/dynamic_stylesheet_onload_fires.rs
@@ -0,0 +1,119 @@
+// Regression for issue #409: a `` inserted via JS
+// (createElement + appendChild) must fetch and fire `load`, so frameworks that
+// await the link's onload (Promise.all of lazy CSS + JS, antd/bootstrap
+// loaders) resolve instead of hanging forever. On main the link neither fires
+// load nor error, so the page stays on stage1.
+
+use obscura_cdp::dispatch::{dispatch, CdpContext};
+use obscura_cdp::types::CdpRequest;
+use serde_json::{json, Value};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+
+// Serves a page that appends a stylesheet link and resolves a promise on load.
+// Also serves the CSS body so the fetch succeeds.
+async fn serve() -> String {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ for _ in 0..4 {
+ let (mut socket, _) = listener.accept().await.unwrap();
+ tokio::spawn(async move {
+ let mut buf = [0u8; 2048];
+ let _ = socket.read(&mut buf).await.unwrap();
+ let req = String::from_utf8_lossy(&buf[..]);
+ let (status, ct, body) = if req.starts_with("GET /style.css") {
+ ("200 OK", "text/css", "body { color: red; }")
+ } else {
+ (
+ "200 OK",
+ "text/html",
+ r#"
+
stage1
+
+"#,
+ )
+ };
+ let resp = format!(
+ "HTTP/1.1 {status}\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = socket.write_all(resp.as_bytes()).await;
+ });
+ }
+ });
+ format!("http://{addr}/")
+}
+
+async fn cdp(ctx: &mut CdpContext, id: u64, method: &str, params: Value, session_id: &str) -> Value {
+ let resp = dispatch(
+ &CdpRequest {
+ id,
+ method: method.to_string(),
+ params,
+ session_id: Some(session_id.to_string()),
+ },
+ ctx,
+ )
+ .await;
+ assert!(resp.error.is_none(), "CDP {method} failed: {:?}", resp.error);
+ resp.result.unwrap_or_else(|| json!({}))
+}
+
+#[tokio::test(flavor = "current_thread")]
+async fn dynamic_stylesheet_fires_load() {
+ std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
+ let url = serve().await;
+ let mut ctx = CdpContext::new();
+ let page_id = ctx.create_page();
+ let session_id = "session-1";
+ ctx.sessions.insert(session_id.to_string(), page_id.clone());
+
+ cdp(
+ &mut ctx,
+ 1,
+ "Page.navigate",
+ json!({"url": url, "waitUntil": "load"}),
+ session_id,
+ )
+ .await;
+
+ // Race the link's onload promise against a 5s timeout. On main the link
+ // never settles, so this rejects with "timeout" and the assertion fails.
+ let v = cdp(
+ &mut ctx,
+ 2,
+ "Runtime.evaluate",
+ json!({
+ "expression": "(async () => { try { return await Promise.race([window.__loaded, new Promise((_, r) => setTimeout(() => r('timeout'), 5000))]); } catch (e) { return 'rejected:' + e; } })()",
+ "awaitPromise": true,
+ "returnByValue": true,
+ }),
+ session_id,
+ )
+ .await;
+ let value = v["result"]["value"].as_str().unwrap_or("");
+ assert_eq!(value, "ok", "dynamic must fire load (got {:?})", value);
+
+ let text = cdp(
+ &mut ctx,
+ 3,
+ "Runtime.evaluate",
+ json!({"expression": "document.getElementById('r').textContent", "returnByValue": true}),
+ session_id,
+ )
+ .await;
+ assert_eq!(
+ text["result"]["value"], "stage2",
+ "the page must advance to stage2 once the stylesheet link loads"
+ );
+}
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 0625061..8d50245 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -198,6 +198,51 @@ async function __processDynScriptQueue() {
__dynScriptBusy = false;
}
}
+// Resolve a resource URL (script src / link href) against or the
+// document URL, the way the inline dynamic-script path does. Guarded so a bad
+// base or href never throws into appendChild.
+function _resolveResourceUrl(src) {
+ let baseHref = null;
+ try {
+ const baseEl = globalThis.document?.querySelector('base[href]');
+ baseHref = baseEl ? baseEl.getAttribute('href') : null;
+ } catch(e) { baseHref = null; }
+ const docUrl = globalThis.location?.href || 'http://localhost/';
+ let baseUrl;
+ try { baseUrl = baseHref ? new URL(baseHref, docUrl).href : docUrl; }
+ catch(e) { baseUrl = docUrl; }
+ try {
+ return src.startsWith('http') || src.startsWith('data:')
+ ? src
+ : new URL(src, baseUrl).href;
+ } catch(e) { return src; }
+}
+
+// A dynamically-inserted must fetch and fire
+// load/error so frameworks awaiting the link's onload (Promise.all of lazy
+// CSS + JS, antd/bootstrap loaders, etc.) resolve instead of hanging forever.
+// There is no layout engine to apply the CSS, but the load-event contract
+// matches Chrome. Issue #409.
+async function _loadLinkedStylesheet(c) {
+ // obscura does not yet reflect the `rel` IDL attribute back to the content
+ // attribute, so `link.rel = "stylesheet"` leaves getAttribute('rel') null.
+ // Read both so the property-assignment form (the common framework pattern)
+ // and the parsed-from-HTML form are both recognized.
+ const rel = (c.getAttribute('rel') || c.rel || '').toString().toLowerCase();
+ if (!rel.split(/\s+/).includes('stylesheet')) return;
+ const href = c.getAttribute('href');
+ if (!href) return;
+ const fullUrl = _resolveResourceUrl(href);
+ let pageOrigin = "";
+ try { pageOrigin = new URL(fullUrl).origin; } catch(e) {}
+ try {
+ await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors");
+ try { c.dispatchEvent(new Event('load', { bubbles: true })); } catch(e) {}
+ } catch(e) {
+ try { c.dispatchEvent(new Event('error', { bubbles: true })); } catch(e) {}
+ }
+}
+
function _fpRand(salt) {
let h = (_fpSeed ^ (salt || 0)) | 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
@@ -687,6 +732,9 @@ class Node {
}
}
}
+ if (c instanceof Element && c.tagName === 'LINK') {
+ _loadLinkedStylesheet(c);
+ }
return c;
}
removeChild(c) {