Fix CDP click submit navigation parity (#69)
* Fix Runtime.evaluate awaitPromise settling * Fix DOM event navigation side effects * Fix CDP click-triggered navigation parity * Strengthen CDP inline script click parity test * Handle positions-like submit URLs in CDP parity Co-authored-by: SGavrl <[email protected]>
This commit is contained in:
1 parent
3a6f3e6c22
commit
6dd1e4e65f
11 files changed
+450
-43
No files matched your search
Vendored
+32
-9
@@ -522,12 +522,14 @@ class Element extends Node {
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
if (!event) return true;
|
||||
event.target = this;
|
||||
if (!event.target) event.target = this;
|
||||
event.currentTarget = this;
|
||||
const handlers = (_eventRegistry[this._nid] || {})[event.type] || [];
|
||||
for (const h of handlers) { try { h.call(this, event); } catch(e) { console.error(e); } }
|
||||
if (event.bubbles && !event.defaultPrevented && this.parentNode) {
|
||||
event.currentTarget = this.parentNode;
|
||||
for (const h of handlers) {
|
||||
try { h.call(this, event); } catch(e) { console.error(e); }
|
||||
if (event._immediatePropagationStopped) break;
|
||||
}
|
||||
if (event.bubbles && !event._propagationStopped && this.parentNode) {
|
||||
this.parentNode.dispatchEvent(event);
|
||||
}
|
||||
return !event.defaultPrevented;
|
||||
@@ -646,7 +648,10 @@ class Element extends Node {
|
||||
}
|
||||
return this._iframeWin;
|
||||
}
|
||||
get action() { return this.getAttribute("action") || ""; }
|
||||
get action() {
|
||||
const action = this.getAttribute("action") || _domParse("document_url") || "";
|
||||
try { return new URL(action, _domParse("document_url") || "about:blank").href; } catch(e) { return action; }
|
||||
}
|
||||
set action(v) { this.setAttribute("action", v); }
|
||||
get method() { return this.getAttribute("method") || "get"; }
|
||||
set method(v) { this.setAttribute("method", v); }
|
||||
@@ -1906,10 +1911,10 @@ globalThis.IntersectionObserver = class {
|
||||
globalThis.PerformanceObserver = class { constructor(){} observe(){} disconnect(){} };
|
||||
|
||||
globalThis.Event = class Event {
|
||||
constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now(); }
|
||||
constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; }
|
||||
get isTrusted() { return true; }
|
||||
preventDefault() { this.defaultPrevented=true; } stopPropagation(){} stopImmediatePropagation(){}
|
||||
initEvent(type,bubbles,cancelable) { this.type=type;this.bubbles=!!bubbles;this.cancelable=!!cancelable; }
|
||||
preventDefault() { if (this.cancelable) this.defaultPrevented=true; } stopPropagation(){ this._propagationStopped=true; } stopImmediatePropagation(){ this._propagationStopped=true; this._immediatePropagationStopped=true; }
|
||||
initEvent(type,bubbles,cancelable) { this.type=type;this.bubbles=!!bubbles;this.cancelable=!!cancelable;this.defaultPrevented=false;this._propagationStopped=false;this._immediatePropagationStopped=false; }
|
||||
};
|
||||
globalThis.CustomEvent = class extends Event {
|
||||
constructor(t,o={}) { super(t,o);this.detail=o.detail; }
|
||||
@@ -1944,7 +1949,25 @@ globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){
|
||||
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
|
||||
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
|
||||
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
|
||||
if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class { constructor(init=""){this._p=new Map();if(typeof init==="string"){init.replace(/^\?/,"").split("&").forEach(p=>{const[k,...v]=p.split("=");if(k)this._p.set(decodeURIComponent(k),decodeURIComponent(v.join("=")));});}} get(k){return this._p.get(k)??null;} set(k,v){this._p.set(k,String(v));} has(k){return this._p.has(k);} toString(){return[...this._p].map(([k,v])=>`${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");} forEach(cb){this._p.forEach((v,k)=>cb(v,k));} };
|
||||
if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class {
|
||||
constructor(init=""){
|
||||
this._p=[];
|
||||
if(typeof init==="string"){
|
||||
init.replace(/^\?/,"").split("&").forEach(p=>{const[k,...v]=p.split("=");if(k)this.append(decodeURIComponent(k),decodeURIComponent(v.join("=")));});
|
||||
} else if (init && typeof init[Symbol.iterator] === 'function') {
|
||||
for (const pair of init) if (pair && pair.length >= 2) this.append(pair[0], pair[1]);
|
||||
} else if (init && typeof init === 'object') {
|
||||
Object.keys(init).forEach(k => this.append(k, init[k]));
|
||||
}
|
||||
}
|
||||
append(k,v){this._p.push([String(k),String(v)]);}
|
||||
get(k){const p=this._p.find(([key])=>key===String(k)); return p?p[1]:null;}
|
||||
set(k,v){this.delete(k); this.append(k,v);}
|
||||
delete(k){k=String(k); this._p=this._p.filter(([key])=>key!==k);}
|
||||
has(k){k=String(k); return this._p.some(([key])=>key===k);}
|
||||
toString(){return this._p.map(([k,v])=>`${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");}
|
||||
forEach(cb){this._p.forEach(([k,v])=>cb(v,k,this));}
|
||||
};
|
||||
|
||||
globalThis.DOMParser = class { parseFromString(s,t) { return globalThis.document; } };
|
||||
globalThis.XMLSerializer = class XMLSerializer {
|
||||
|
||||
@@ -664,6 +664,7 @@ fn op_set_cookie(state: &OpState, #[string] cookie_str: &str) {
|
||||
fn op_navigate(state: &OpState, #[string] url: &str, #[string] method: &str, #[string] body: &str) {
|
||||
let gs = state.borrow::<SharedState>().clone();
|
||||
let mut gs = gs.borrow_mut();
|
||||
gs.url = url.to_string();
|
||||
gs.pending_navigation = Some((url.to_string(), method.to_string(), body.to_string()));
|
||||
}
|
||||
|
||||
|
||||
@@ -119,12 +119,13 @@ impl ObscuraJsRuntime {
|
||||
self.v8_to_json(result)
|
||||
}
|
||||
|
||||
pub fn evaluate_for_cdp(
|
||||
pub async fn evaluate_for_cdp(
|
||||
&mut self,
|
||||
expression: &str,
|
||||
return_by_value: bool,
|
||||
await_promise: bool,
|
||||
) -> Result<RemoteObjectInfo, String> {
|
||||
if return_by_value {
|
||||
if !await_promise && return_by_value {
|
||||
let val = self.evaluate(expression)?;
|
||||
return Ok(Self::info_from_json(&val));
|
||||
}
|
||||
@@ -139,24 +140,59 @@ impl ObscuraJsRuntime {
|
||||
.trim()
|
||||
.trim_end_matches(|c: char| c == ';' || c.is_whitespace());
|
||||
|
||||
let meta_code = format!(
|
||||
"(function() {{\n\
|
||||
var __result;\n\
|
||||
try {{ __result = ({expr}); }} catch(e) {{ __result = undefined; }}\n\
|
||||
globalThis.__obscura_objects['{oid}'] = __result;\n\
|
||||
return {meta_fn};\n\
|
||||
}})()",
|
||||
expr = cleaned_expr,
|
||||
oid = oid,
|
||||
meta_fn = Self::meta_extract_js("__result"),
|
||||
);
|
||||
let meta_code = if await_promise {
|
||||
format!(
|
||||
"(async function() {{\n\
|
||||
try {{\n\
|
||||
var __result = await ({expr});\n\
|
||||
globalThis.__obscura_objects['{oid}'] = __result;\n\
|
||||
globalThis.__obscura_await_meta = {meta_fn};\n\
|
||||
globalThis.__obscura_await_rejected = false;\n\
|
||||
}} catch(e) {{\n\
|
||||
globalThis.__obscura_objects['{oid}'] = e;\n\
|
||||
globalThis.__obscura_await_meta = {err_meta_fn};\n\
|
||||
globalThis.__obscura_await_rejected = true;\n\
|
||||
}}\n\
|
||||
}})()",
|
||||
expr = cleaned_expr,
|
||||
oid = oid,
|
||||
meta_fn = Self::meta_extract_js("__result"),
|
||||
err_meta_fn = Self::meta_extract_js("e"),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"(function() {{\n\
|
||||
var __result;\n\
|
||||
try {{ __result = ({expr}); }} catch(e) {{ __result = undefined; }}\n\
|
||||
globalThis.__obscura_objects['{oid}'] = __result;\n\
|
||||
return {meta_fn};\n\
|
||||
}})()",
|
||||
expr = cleaned_expr,
|
||||
oid = oid,
|
||||
meta_fn = Self::meta_extract_js("__result"),
|
||||
)
|
||||
};
|
||||
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<eval-remote>", meta_code)
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
|
||||
let meta_str = self.v8_to_json(result)?;
|
||||
let meta_str = if await_promise {
|
||||
self.resolve_promises().await;
|
||||
let rejected = self.runtime.execute_script("<readRejected>", "globalThis.__obscura_await_rejected".to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
if self.v8_to_json(rejected)?.as_bool().unwrap_or(false) {
|
||||
let err = self.runtime.execute_script("<readError>", format!("String(globalThis.__obscura_objects['{0}'] && (globalThis.__obscura_objects['{0}'].message || globalThis.__obscura_objects['{0}']))", oid))
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
return Err(format!("Promise rejected: {}", self.v8_to_json(err)?.as_str().unwrap_or("")));
|
||||
}
|
||||
self.runtime.execute_script("<readMeta>", "globalThis.__obscura_await_meta".to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?
|
||||
} else {
|
||||
result
|
||||
};
|
||||
let meta_str = self.v8_to_json(meta_str)?;
|
||||
let meta_json = if let serde_json::Value::String(s) = &meta_str {
|
||||
serde_json::from_str(s).unwrap_or(meta_str)
|
||||
} else {
|
||||
@@ -167,6 +203,13 @@ impl ObscuraJsRuntime {
|
||||
format!("globalThis.__obscura_objects['{}']", oid),
|
||||
);
|
||||
|
||||
if await_promise && return_by_value {
|
||||
let read = self.runtime.execute_script("<readResult>", format!("globalThis.__obscura_objects['{}']", oid))
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
let json_val = self.v8_to_json(read)?;
|
||||
return Ok(Self::info_from_json(&json_val));
|
||||
}
|
||||
|
||||
Ok(Self::info_from_meta(&meta_json, Some(oid)))
|
||||
}
|
||||
|
||||
@@ -529,7 +572,7 @@ impl ObscuraJsRuntime {
|
||||
|
||||
pub async fn resolve_promises(&mut self) {
|
||||
let _ = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(100),
|
||||
tokio::time::Duration::from_secs(5),
|
||||
self.runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
|
||||
).await;
|
||||
}
|
||||
@@ -904,6 +947,61 @@ mod tests {
|
||||
assert_eq!(href, serde_json::json!("http://example.com/test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_button_click_dispatches_listener() {
|
||||
let mut rt = setup_runtime(r#"<button id="go">Go</button>"#);
|
||||
let result = rt.evaluate(r#"
|
||||
const button = document.getElementById('go');
|
||||
button.addEventListener('click', () => { button.dataset.clicked = 'yes'; });
|
||||
button.click();
|
||||
return button.dataset.clicked;
|
||||
"#).unwrap();
|
||||
assert_eq!(result, serde_json::json!("yes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_mouse_event_runs_listener() {
|
||||
let mut rt = setup_runtime(r#"<button id="go">Go</button>"#);
|
||||
let result = rt.evaluate(r#"
|
||||
const button = document.getElementById('go');
|
||||
let count = 0;
|
||||
button.addEventListener('click', () => { count += 1; });
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
return count;
|
||||
"#).unwrap();
|
||||
assert_eq!(result.as_f64().unwrap() as i64, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_location_href_assignment_updates_navigation_state() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let href = rt.evaluate("const next = '/next'; location.href = next; return location.href;").unwrap();
|
||||
assert_eq!(href, serde_json::json!("http://example.com/next"));
|
||||
assert_eq!(
|
||||
rt.take_pending_navigation(),
|
||||
Some(("http://example.com/next".to_string(), "GET".to_string(), "".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_button_click_handler_can_prevent_default_and_navigate() {
|
||||
let mut rt = setup_runtime(r#"<form><button type="submit" id="submit">Submit</button></form>"#);
|
||||
let href = rt.evaluate(r#"
|
||||
const form = document.querySelector('form');
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
location.href = '/submitted';
|
||||
});
|
||||
document.getElementById('submit').click();
|
||||
return location.href;
|
||||
"#).unwrap();
|
||||
assert_eq!(href, serde_json::json!("http://example.com/submitted"));
|
||||
assert_eq!(
|
||||
rt.take_pending_navigation(),
|
||||
Some(("http://example.com/submitted".to_string(), "GET".to_string(), "".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigator() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
@@ -986,25 +1084,54 @@ mod tests {
|
||||
assert_eq!(result2.value.unwrap().as_f64().unwrap() as i64, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_for_cdp_detects_node() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_detects_node() {
|
||||
let mut rt = setup_runtime("<html><body><h1>Hello</h1></body></html>");
|
||||
let result = rt
|
||||
.evaluate_for_cdp("document.querySelector('h1')", false)
|
||||
.unwrap();
|
||||
.evaluate_for_cdp("document.querySelector('h1')", false, false)
|
||||
.await.unwrap();
|
||||
assert_eq!(result.subtype.as_deref(), Some("node"));
|
||||
assert_eq!(result.js_type, "object");
|
||||
assert!(result.object_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_for_cdp_detects_document() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_detects_document() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt.evaluate_for_cdp("document", false).unwrap();
|
||||
let result = rt.evaluate_for_cdp("document", false, false).await.unwrap();
|
||||
assert_eq!(result.subtype.as_deref(), Some("node"));
|
||||
assert_eq!(result.class_name, "HTMLDocument");
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_awaits_resolved_promise() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt.evaluate_for_cdp("Promise.resolve(42)", true, true).await.unwrap();
|
||||
assert_eq!(result.value.unwrap().as_f64().unwrap() as i64, 42);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_awaits_timer_promise() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt.evaluate_for_cdp("new Promise(resolve => setTimeout(() => resolve('done'), 1))", true, true).await.unwrap();
|
||||
assert_eq!(result.value.unwrap().as_str().unwrap(), "done");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_awaits_async_function() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt.evaluate_for_cdp("(async () => 'async-ok')()", true, true).await.unwrap();
|
||||
assert_eq!(result.value.unwrap().as_str().unwrap(), "async-ok");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_evaluate_for_cdp_reports_promise_rejection() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let err = rt.evaluate_for_cdp("Promise.reject(new Error('boom'))", true, true).await.unwrap_err();
|
||||
assert!(err.contains("boom"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_call_function_on_dom_interaction() {
|
||||
let mut rt = setup_runtime(r#"<div id="items"><span>A</span><span>B</span></div>"#);
|
||||
|
||||
Reference in new issue
Block a user