Merge remote-tracking branch 'origin/main' into maintainer/pr-514-review
# Conflicts: # crates/obscura-js/js/bootstrap.js
This commit is contained in:
commit
1518ca2a70
9 files changed
+764
-80
No files matched your search
@@ -236,6 +236,25 @@ pub async fn handle(
|
||||
let _ = page.evaluate(&code);
|
||||
Ok(json!({}))
|
||||
}
|
||||
"scrollIntoViewIfNeeded" => {
|
||||
let page = ctx.get_session_page_mut(session_id).ok_or("No page")?;
|
||||
let node_id = resolve_node_id(page, params)?;
|
||||
// Obscura has no layout viewport to move, but the JS shim records
|
||||
// this element for the hit testing used by subsequent input events.
|
||||
let code = format!(
|
||||
"(function() {{ var el = globalThis._wrap && globalThis._wrap({0}); \
|
||||
if (!el || typeof el.scrollIntoView !== 'function') return false; \
|
||||
el.scrollIntoView(); return true; }})()",
|
||||
node_id
|
||||
);
|
||||
let did_scroll = page.evaluate(&code).as_bool().unwrap_or(false);
|
||||
if !did_scroll {
|
||||
return Err(format!(
|
||||
"node {node_id} could not be resolved to a scrollable element"
|
||||
));
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
"setFileInputFiles" => {
|
||||
// Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles
|
||||
// drive an <input type=file> through this CDP call (issue #359). Read
|
||||
@@ -526,6 +545,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scroll_into_view_if_needed_resolves_all_node_identifiers() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session = Some(format!("{page_id}-session"));
|
||||
ctx.sessions.insert(session.clone().unwrap(), page_id);
|
||||
|
||||
crate::domains::page::handle(
|
||||
"navigate",
|
||||
&json!({
|
||||
"url": "data:text/html,<main><button id=target>Go</button></main>",
|
||||
"waitUntil": "load"
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("navigate should succeed");
|
||||
|
||||
let query = handle(
|
||||
"querySelector",
|
||||
&json!({ "selector": "#target" }),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("querySelector should succeed");
|
||||
let node_id = query["nodeId"].as_u64().expect("button nodeId");
|
||||
|
||||
let resolved = handle(
|
||||
"resolveNode",
|
||||
&json!({ "nodeId": node_id }),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("resolveNode should succeed");
|
||||
let object_id = resolved["object"]["objectId"]
|
||||
.as_str()
|
||||
.expect("button objectId")
|
||||
.to_string();
|
||||
|
||||
for params in [
|
||||
json!({ "nodeId": node_id }),
|
||||
json!({ "backendNodeId": node_id }),
|
||||
json!({ "objectId": object_id }),
|
||||
] {
|
||||
ctx.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.evaluate("globalThis.__obscura_click_target = null");
|
||||
|
||||
handle("scrollIntoViewIfNeeded", ¶ms, &mut ctx, &session)
|
||||
.await
|
||||
.expect("scrollIntoViewIfNeeded should succeed");
|
||||
|
||||
let target_id = ctx
|
||||
.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.evaluate("globalThis.__obscura_click_target && globalThis.__obscura_click_target.id");
|
||||
assert_eq!(target_id, json!("target"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scroll_into_view_if_needed_requires_a_node_identifier() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session = Some(format!("{page_id}-session"));
|
||||
ctx.sessions.insert(session.clone().unwrap(), page_id);
|
||||
|
||||
let error = handle(
|
||||
"scrollIntoViewIfNeeded",
|
||||
&json!({}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing node identifier should fail");
|
||||
|
||||
assert_eq!(error, "nodeId, backendNodeId, or objectId required");
|
||||
|
||||
let error = handle(
|
||||
"scrollIntoViewIfNeeded",
|
||||
&json!({ "nodeId": 999_999 }),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect_err("an unknown node should fail");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
"node 999999 could not be resolved to a scrollable element"
|
||||
);
|
||||
}
|
||||
|
||||
// A hostile or heavy page can nest nodes tens of thousands deep (trivially
|
||||
// scriptable, and html5ever puts no cap on generic nesting). DOM.getDocument
|
||||
// with the standard depth:-1 (which becomes u32::MAX here) must handle such a
|
||||
|
||||
@@ -7,4 +7,4 @@ pub mod selector;
|
||||
pub mod serialize;
|
||||
|
||||
pub use tree::{Attribute, DomTree, Node, NodeData, NodeId};
|
||||
pub use tree_sink::{parse_html, parse_fragment};
|
||||
pub use tree_sink::{parse_fragment, parse_fragment_with_context, parse_html};
|
||||
@@ -580,6 +580,10 @@ impl DomTree {
|
||||
// Fast path: a bare "#id" selector resolves through the id index in O(1)
|
||||
// instead of scanning every descendant. The index holds the first element
|
||||
// in tree order per id, which is exactly what the full scan would return.
|
||||
// In quirks mode `#id` matches ASCII-case-insensitively, but the id
|
||||
// index is keyed on the exact-case id, so skip the fast path and let the
|
||||
// selector engine (below) do the case-insensitive match.
|
||||
if !self.is_quirks() {
|
||||
if let Some(id) = simple_id_selector(selector) {
|
||||
match self.get_element_by_id(id) {
|
||||
// querySelector matches strict descendants of root only, so the
|
||||
@@ -592,13 +596,14 @@ impl DomTree {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let selector_list = parse_selector(selector)?;
|
||||
let mut caches = selectors::context::SelectorCaches::default();
|
||||
let mut context = MatchingContext::new(
|
||||
MatchingMode::Normal,
|
||||
None,
|
||||
&mut caches,
|
||||
QuirksMode::NoQuirks,
|
||||
self.selector_quirks_mode(),
|
||||
NeedsSelectorFlags::No,
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
@@ -619,6 +624,16 @@ impl DomTree {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// Map the document's quirks flag onto the selector crate's QuirksMode. In
|
||||
// quirks mode the crate matches class/id selectors ASCII-case-insensitively.
|
||||
fn selector_quirks_mode(&self) -> QuirksMode {
|
||||
if self.is_quirks() {
|
||||
QuirksMode::Quirks
|
||||
} else {
|
||||
QuirksMode::NoQuirks
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
|
||||
let selector_list = parse_selector(selector)?;
|
||||
let mut caches = selectors::context::SelectorCaches::default();
|
||||
@@ -626,7 +641,7 @@ impl DomTree {
|
||||
MatchingMode::Normal,
|
||||
None,
|
||||
&mut caches,
|
||||
QuirksMode::NoQuirks,
|
||||
self.selector_quirks_mode(),
|
||||
NeedsSelectorFlags::No,
|
||||
MatchingForInvalidation::No,
|
||||
);
|
||||
@@ -729,6 +744,34 @@ mod tests {
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quirks_mode_matches_class_and_id_case_insensitively() {
|
||||
// No doctype => quirks mode; class/id match ASCII case-insensitively.
|
||||
let tree = parse_html(r#"<div class="Foo" id="Bar">x</div>"#);
|
||||
assert!(
|
||||
tree.query_selector(".foo").unwrap().is_some(),
|
||||
".foo should match class=\"Foo\" in quirks mode"
|
||||
);
|
||||
assert!(
|
||||
tree.query_selector("#bar").unwrap().is_some(),
|
||||
"#bar should match id=\"Bar\" in quirks mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_mode_matches_class_and_id_case_sensitively() {
|
||||
// With a doctype => no-quirks; class/id remain case-sensitive.
|
||||
let tree = parse_html(r#"<!DOCTYPE html><div class="Foo" id="Bar">x</div>"#);
|
||||
assert!(
|
||||
tree.query_selector(".foo").unwrap().is_none(),
|
||||
".foo must NOT match class=\"Foo\" in standards mode"
|
||||
);
|
||||
assert!(
|
||||
tree.query_selector("#bar").unwrap().is_none(),
|
||||
"#bar must NOT match id=\"Bar\" in standards mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_selector_complex() {
|
||||
let tree = parse_html(
|
||||
|
||||
@@ -207,6 +207,9 @@ pub(crate) struct DomTreeInner {
|
||||
pub(crate) free_list: Vec<u32>,
|
||||
pub(crate) document: NodeId,
|
||||
pub(crate) id_index: HashMap<String, NodeId>,
|
||||
// Whether the document was parsed in (full) quirks mode. In quirks mode CSS
|
||||
// class and id selectors match ASCII-case-insensitively.
|
||||
pub(crate) quirks: bool,
|
||||
}
|
||||
|
||||
impl DomTree {
|
||||
@@ -226,6 +229,7 @@ impl DomTree {
|
||||
free_list: Vec::new(),
|
||||
document: NodeId(0),
|
||||
id_index: HashMap::new(),
|
||||
quirks: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -234,6 +238,17 @@ impl DomTree {
|
||||
self.inner.borrow().document
|
||||
}
|
||||
|
||||
/// Record whether the document was parsed in (full) quirks mode.
|
||||
pub fn set_quirks(&self, quirks: bool) {
|
||||
self.inner.borrow_mut().quirks = quirks;
|
||||
}
|
||||
|
||||
/// Whether the document is in (full) quirks mode, in which CSS class and id
|
||||
/// selectors match ASCII-case-insensitively.
|
||||
pub fn is_quirks(&self) -> bool {
|
||||
self.inner.borrow().quirks
|
||||
}
|
||||
|
||||
pub(crate) fn borrow_inner(&self) -> std::cell::Ref<'_, DomTreeInner> {
|
||||
self.inner.borrow()
|
||||
}
|
||||
@@ -533,13 +548,21 @@ impl DomTree {
|
||||
inner.id_index.remove(&id_str);
|
||||
}
|
||||
|
||||
// Only free slots that are currently live. Freeing an out-of-range id
|
||||
// would panic on direct indexing, and freeing an already-freed slot
|
||||
// would push it onto the free list a second time — later handing the
|
||||
// same NodeId to two live nodes (aliasing).
|
||||
for desc_id in descendants {
|
||||
if matches!(inner.nodes.get(desc_id.index()), Some(Some(_))) {
|
||||
inner.nodes[desc_id.index()] = None;
|
||||
inner.free_list.push(desc_id.0);
|
||||
}
|
||||
}
|
||||
if matches!(inner.nodes.get(node_id.index()), Some(Some(_))) {
|
||||
inner.nodes[node_id.index()] = None;
|
||||
inner.free_list.push(node_id.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn children(&self, node_id: NodeId) -> Vec<NodeId> {
|
||||
let inner = self.inner.borrow();
|
||||
@@ -996,6 +1019,30 @@ mod tests {
|
||||
assert!(node.is_document());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_out_of_range_id_is_a_noop() {
|
||||
let tree = DomTree::new();
|
||||
// Direct indexing into `nodes` panicked out-of-bounds for an id past the
|
||||
// end of the slot vector; it must be a no-op instead.
|
||||
tree.remove(NodeId::new(9999));
|
||||
assert_eq!(tree.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_twice_does_not_alias_slots() {
|
||||
let tree = DomTree::new();
|
||||
let doc = tree.document();
|
||||
let a = tree.new_node(NodeData::Text { contents: "a".into() });
|
||||
tree.append_child(doc, a);
|
||||
tree.remove(a);
|
||||
// Removing the already-freed node again must not push its slot onto the
|
||||
// free list a second time, or two later allocations alias one slot.
|
||||
tree.remove(a);
|
||||
let x = tree.new_node(NodeData::Text { contents: "x".into() });
|
||||
let y = tree.new_node(NodeData::Text { contents: "y".into() });
|
||||
assert_ne!(x, y, "double-free aliased two live nodes onto the same slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_child() {
|
||||
let tree = DomTree::new();
|
||||
|
||||
@@ -218,7 +218,10 @@ impl TreeSink for DomTree {
|
||||
x == y
|
||||
}
|
||||
|
||||
fn set_quirks_mode(&self, _mode: QuirksMode) {
|
||||
fn set_quirks_mode(&self, mode: QuirksMode) {
|
||||
// Only full quirks mode makes CSS class/id selectors case-insensitive;
|
||||
// limited-quirks behaves like no-quirks for selector matching.
|
||||
self.set_quirks(mode == QuirksMode::Quirks);
|
||||
}
|
||||
|
||||
fn is_mathml_annotation_xml_integration_point(&self, target: &NodeId) -> bool {
|
||||
@@ -243,10 +246,14 @@ pub fn parse_html(html: &str) -> DomTree {
|
||||
}
|
||||
|
||||
pub fn parse_fragment(html: &str) -> DomTree {
|
||||
use html5ever::tendril::TendrilSink;
|
||||
use html5ever::{parse_fragment, ParseOpts, QualName};
|
||||
|
||||
let context_name = QualName::new(None, ns!(html), local_name!("body"));
|
||||
parse_fragment_with_context(html, context_name)
|
||||
}
|
||||
|
||||
pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree {
|
||||
use html5ever::tendril::TendrilSink;
|
||||
use html5ever::{parse_fragment, ParseOpts};
|
||||
|
||||
let tree = DomTree::new();
|
||||
parse_fragment(tree, ParseOpts::default(), context_name, vec![])
|
||||
.from_utf8()
|
||||
|
||||
Vendored
+215
-72
@@ -24,7 +24,7 @@
|
||||
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
|
||||
'_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
|
||||
'_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
|
||||
'_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl',
|
||||
'_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_elementClassFor', '_wrap', '_wrapEl',
|
||||
'_resolveUrl', '_registerIframe', '_base64ToUint8Array',
|
||||
'_bodyToUint8Array', '_arrayBufferFromBytes',
|
||||
'_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp',
|
||||
@@ -36,6 +36,18 @@
|
||||
'_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset',
|
||||
'_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase',
|
||||
'_makeListenerBox',
|
||||
// WebIDL interfaces. A real browser exposes these on the global as
|
||||
// enumerable:false; here they were assigned with `globalThis.X = X`, which
|
||||
// defaults to enumerable:true and is detectable in one line:
|
||||
// Object.getOwnPropertyDescriptor(window, 'Node').enumerable
|
||||
// Pre-declaring them non-enumerable here is enough -- per the note above,
|
||||
// the later `globalThis.X = X` assignments only update the value.
|
||||
'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType',
|
||||
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
|
||||
'CSSStyleDeclaration', 'DOMTokenList', 'Screen', 'NetworkInformation',
|
||||
'MessageChannel', 'MessagePort', 'CustomElementRegistry',
|
||||
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
|
||||
'HTMLAudioElement', 'WebGL2RenderingContext',
|
||||
];
|
||||
var _desc = { value: undefined, writable: true, enumerable: false, configurable: true };
|
||||
for (var _i = 0; _i < _names.length; _i++) {
|
||||
@@ -456,12 +468,24 @@ const _scheduleAfter = (delay, fn) => {
|
||||
else Deno.core.ops.op_sleep(d).then(fn);
|
||||
};
|
||||
|
||||
// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF
|
||||
// `acw_sc__v2` challenge drives `setTimeout('reload(arg2)', 2)`). A string is
|
||||
// compiled and run in global scope, identical to a real browser; otherwise the
|
||||
// call silently no-ops and JS-triggered navigations (cookie → reload) never fire.
|
||||
const _coerceTimerFn = (fn) => {
|
||||
if (typeof fn === "string") {
|
||||
try { return new Function(fn); } catch (_) { return null; }
|
||||
}
|
||||
return typeof fn === "function" ? fn : null;
|
||||
};
|
||||
|
||||
globalThis.setTimeout = (fn, delay = 0, ...args) => {
|
||||
if (typeof fn !== "function") return ++_tid;
|
||||
const f = _coerceTimerFn(fn);
|
||||
if (f === null) return ++_tid;
|
||||
const id = ++_tid;
|
||||
_scheduleAfter(delay, () => {
|
||||
if (_clearedTimers.has(id)) return;
|
||||
try { fn(...args); } catch(e) { console.error("Timer error:", e); }
|
||||
try { f(...args); } catch(e) { console.error("Timer error:", e); }
|
||||
});
|
||||
return id;
|
||||
};
|
||||
@@ -469,12 +493,13 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => {
|
||||
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
|
||||
|
||||
globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||
if (typeof fn !== "function") return ++_tid;
|
||||
const f = _coerceTimerFn(fn);
|
||||
if (f === null) return ++_tid;
|
||||
const id = ++_tid;
|
||||
_intervals.add(id);
|
||||
const tick = () => {
|
||||
if (!_intervals.has(id)) return;
|
||||
try { fn(...args); } catch(e) { console.error("Interval error:", e); }
|
||||
try { f(...args); } catch(e) { console.error("Interval error:", e); }
|
||||
if (!_intervals.has(id)) return;
|
||||
_scheduleAfter(delay, tick);
|
||||
};
|
||||
@@ -564,35 +589,75 @@ const _CSS_PROPERTY_NAMES = [
|
||||
];
|
||||
const _CSS_PROP_SET = new Set(_CSS_PROPERTY_NAMES);
|
||||
|
||||
// Parse a `style` attribute string (`"color: red; margin: 5px"`) into the given
|
||||
// dashed-key store, replacing its contents in place.
|
||||
function _parseCssInto(props, text) {
|
||||
for (const k in props) delete props[k];
|
||||
if (text) String(text).split(";").forEach((p) => {
|
||||
const i = p.indexOf(":");
|
||||
if (i > 0) { const k = p.slice(0, i).trim(); const v = p.slice(i + 1).trim(); if (k && v) props[_cssCamelToKebab(k)] = v; }
|
||||
});
|
||||
}
|
||||
function _serializeCss(props) {
|
||||
const e = Object.entries(props);
|
||||
return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : "";
|
||||
}
|
||||
|
||||
class CSSStyleDeclaration {
|
||||
constructor() {
|
||||
// Non-enumerable so it never leaks through the proxy's own-key traps.
|
||||
constructor(owner) {
|
||||
// Non-enumerable so they never leak through the proxy's own-key traps.
|
||||
Object.defineProperty(this, "_props", { value: {}, writable: true, enumerable: false, configurable: true });
|
||||
// The owner Element, if any. A live declaration reflects that element's
|
||||
// `style` content attribute in both directions; an owner-less declaration
|
||||
// (getComputedStyle fallback, stylesheet rules) is purely in-memory.
|
||||
Object.defineProperty(this, "_owner", { value: owner || null, writable: true, enumerable: false, configurable: true });
|
||||
// Load the content attribute only when style is first observed. Keeping
|
||||
// this as a primitive avoids allocating a separate sync object for every
|
||||
// wrapped element.
|
||||
Object.defineProperty(this, "_loaded", { value: !owner, writable: true, enumerable: false, configurable: true });
|
||||
}
|
||||
// Pull the initial `style` attribute once. Later attribute mutations update
|
||||
// the declaration directly from Element.setAttribute/removeAttribute, so
|
||||
// repeated style reads do not cross the JS/Rust op boundary.
|
||||
_pull() {
|
||||
if (this._loaded) return;
|
||||
_parseCssInto(this._props, this._owner.getAttribute("style"));
|
||||
this._loaded = true;
|
||||
}
|
||||
_replaceFromAttribute(text) {
|
||||
_parseCssInto(this._props, text);
|
||||
this._loaded = true;
|
||||
}
|
||||
// Serialize `_props` back onto the owner's `style` attribute after a mutation,
|
||||
// so el.style.x = … and cssText reflect into getAttribute('style') and
|
||||
// serialization. No-op when owner-less.
|
||||
_push() {
|
||||
const o = this._owner;
|
||||
if (!o) return;
|
||||
const text = _serializeCss(this._props);
|
||||
if (text) o.setAttribute("style", text);
|
||||
else o.removeAttribute("style");
|
||||
}
|
||||
// Storage is keyed by the dashed CSS name, matching CSSOM. The proxy maps the
|
||||
// camelCase IDL access (el.style.fontSize) onto the dashed key (font-size), so
|
||||
// getPropertyValue('font-size') and el.style.fontSize stay in sync.
|
||||
setProperty(name, value) {
|
||||
this._pull();
|
||||
const k = _cssCamelToKebab(String(name));
|
||||
if (value === "" || value == null) { delete this._props[k]; return; }
|
||||
this._props[k] = String(value);
|
||||
if (value === "" || value == null) delete this._props[k];
|
||||
else this._props[k] = String(value);
|
||||
this._push();
|
||||
}
|
||||
removeProperty(name) { const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; return old || ""; }
|
||||
getPropertyValue(name) { return this._props[_cssCamelToKebab(String(name))] || ""; }
|
||||
removeProperty(name) { this._pull(); const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; this._push(); return old || ""; }
|
||||
getPropertyValue(name) { this._pull(); return this._props[_cssCamelToKebab(String(name))] || ""; }
|
||||
getPropertyPriority() { return ""; }
|
||||
get cssText() {
|
||||
const e = Object.entries(this._props);
|
||||
return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : "";
|
||||
}
|
||||
get cssText() { this._pull(); return _serializeCss(this._props); }
|
||||
set cssText(v) {
|
||||
for (const k in this._props) delete this._props[k];
|
||||
if (v) String(v).split(";").forEach((p) => {
|
||||
const i = p.indexOf(":");
|
||||
if (i > 0) { const k = p.slice(0, i).trim(); const val = p.slice(i + 1).trim(); if (k && val) this._props[_cssCamelToKebab(k)] = val; }
|
||||
});
|
||||
_parseCssInto(this._props, v);
|
||||
this._push();
|
||||
}
|
||||
get length() { return Object.keys(this._props).length; }
|
||||
item(i) { return Object.keys(this._props)[i] || ""; }
|
||||
get length() { this._pull(); return Object.keys(this._props).length; }
|
||||
item(i) { this._pull(); return Object.keys(this._props)[i] || ""; }
|
||||
}
|
||||
|
||||
const _styleProxy = (decl) => new Proxy(decl, {
|
||||
@@ -603,6 +668,7 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
||||
},
|
||||
set(t, p, v) {
|
||||
if (typeof p === "symbol") { t[p] = v; return true; }
|
||||
if (p === "_loaded") { t._loaded = v; return true; }
|
||||
if (p === "cssText") { t.cssText = v; return true; }
|
||||
if (/^\d+$/.test(p) || p in Object.getPrototypeOf(t)) return true;
|
||||
t.setProperty(p, v);
|
||||
@@ -611,11 +677,13 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
||||
has(t, p) {
|
||||
if (typeof p !== "string") return Reflect.has(t, p);
|
||||
if (p in Object.getPrototypeOf(t)) return true;
|
||||
t._pull();
|
||||
if (_cssCamelToKebab(p) in t._props) return true;
|
||||
if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true;
|
||||
return /^\d+$/.test(p) && +p < t.length;
|
||||
},
|
||||
ownKeys(t) {
|
||||
t._pull();
|
||||
const keys = [];
|
||||
const n = t.length;
|
||||
for (let i = 0; i < n; i++) keys.push(String(i));
|
||||
@@ -626,6 +694,7 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
||||
},
|
||||
getOwnPropertyDescriptor(t, p) {
|
||||
if (typeof p !== "string") return Reflect.getOwnPropertyDescriptor(t, p);
|
||||
t._pull();
|
||||
if (/^\d+$/.test(p) && +p < t.length) return { value: t.item(+p), writable: false, enumerable: true, configurable: true };
|
||||
if (_cssCamelToKebab(p) in t._props || _CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) {
|
||||
return { value: t.getPropertyValue(p), writable: true, enumerable: true, configurable: true };
|
||||
@@ -634,6 +703,33 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
||||
},
|
||||
});
|
||||
|
||||
// Clone a single node (no children), used by Node.cloneNode. Elements are built
|
||||
// with createElement/createElementNS and their content attributes copied, so no
|
||||
// HTML parsing context is involved and every attribute (including style) is
|
||||
// preserved. Text/Comment/DocumentFragment map to their factory; anything else
|
||||
// yields null.
|
||||
function _shallowCloneNode(node) {
|
||||
const nt = node.nodeType;
|
||||
if (nt === 3) return document.createTextNode(node.data != null ? node.data : (node.textContent || ""));
|
||||
if (nt === 8) return document.createComment(node.data != null ? node.data : (node.nodeValue || ""));
|
||||
if (nt === 11) return document.createDocumentFragment();
|
||||
if (nt !== 1) return null;
|
||||
const ns = node.namespaceURI;
|
||||
const el = (ns && ns !== "http://www.w3.org/1999/xhtml")
|
||||
? document.createElementNS(ns, node.nodeName)
|
||||
: document.createElement(node.localName || node.nodeName.toLowerCase());
|
||||
const names = node.getAttributeNames ? node.getAttributeNames() : [];
|
||||
for (const name of names) {
|
||||
const v = node.getAttribute(name);
|
||||
if (v !== null) el.setAttribute(name, v);
|
||||
}
|
||||
// CSS declarations currently live on the JS wrapper independently of the
|
||||
// DOM attribute. Copy that state as well so styles assigned through
|
||||
// `node.style` survive cloning even before attribute reflection runs.
|
||||
if (node.style && node.style.cssText) el.style.cssText = node.style.cssText;
|
||||
return el;
|
||||
}
|
||||
|
||||
class Node {
|
||||
static ELEMENT_NODE = 1;
|
||||
static ATTRIBUTE_NODE = 2;
|
||||
@@ -824,32 +920,38 @@ class Node {
|
||||
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
|
||||
hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; }
|
||||
cloneNode(deep) {
|
||||
const t = this.nodeType;
|
||||
if (t === 1) {
|
||||
if (deep) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = _domParse("outer_html", this._nid) || "";
|
||||
const clone = wrapper.firstChild;
|
||||
return clone;
|
||||
}
|
||||
const el = document.createElement(this.nodeName.toLowerCase());
|
||||
const html = _domParse("outer_html", this._nid) || "";
|
||||
const attrMatch = html.match(/^<[a-zA-Z][^\s>]*([\s\S]*?)>/);
|
||||
if (attrMatch && attrMatch[1]) {
|
||||
const attrStr = attrMatch[1].trim();
|
||||
const re = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrStr)) !== null) {
|
||||
const name = m[1];
|
||||
const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || "";
|
||||
if (name !== this.nodeName.toLowerCase()) el.setAttribute(name, val);
|
||||
// Clone structurally via real DOM nodes rather than round-tripping through a
|
||||
// throwaway <div>.innerHTML: the fragment parser discards elements that are
|
||||
// not valid children of <div> (<tr>, <td>, <option>, …), so the old path
|
||||
// returned null for them and lost JS-set inline styles. Building each node
|
||||
// directly with createElement(NS) + attribute copy avoids any parsing
|
||||
// context, and an explicit stack keeps a deep subtree from overflowing the
|
||||
// JS stack (issue #490).
|
||||
const root = _shallowCloneNode(this);
|
||||
if (!deep || !root) return root;
|
||||
const stack = [[this, root]];
|
||||
while (stack.length) {
|
||||
const [src, dst] = stack.pop();
|
||||
// A <template>'s children hang off its content fragment, not childNodes,
|
||||
// so clone them into the clone's fragment. Gated on the tag name because
|
||||
// .content means something else on other elements (e.g. <meta>).
|
||||
if (src.localName === 'template' && dst.localName === 'template') {
|
||||
const sc = src.content, dc = dst.content;
|
||||
if (sc && dc && sc.childNodes) {
|
||||
const tk = sc.childNodes;
|
||||
for (let i = 0; i < tk.length; i++) {
|
||||
const c = _shallowCloneNode(tk[i]);
|
||||
if (c) { dc.appendChild(c); stack.push([tk[i], c]); }
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
if (t === 3) return document.createTextNode(this.textContent);
|
||||
if (t === 8) return document.createComment(this.nodeValue || "");
|
||||
return null;
|
||||
const kids = src.childNodes;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
const c = _shallowCloneNode(kids[i]);
|
||||
if (c) { dst.appendChild(c); stack.push([kids[i], c]); }
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
compareDocumentPosition(other) {
|
||||
if (!other) return 0;
|
||||
@@ -1227,10 +1329,27 @@ function _isSubmitButton(el) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse an HTML string into detached nodes using the actual insertion element
|
||||
// as html5ever's fragment context. This preserves table/select parsing rules,
|
||||
// comments, text-node order, and foreign-content namespaces without a wrap map.
|
||||
function _parseHTMLFragment(html, context) {
|
||||
html = String(html == null ? '' : html);
|
||||
const ns = context && context.nodeType === 1 ? context.namespaceURI : null;
|
||||
const tag = context && context.nodeType === 1 ? context.localName : 'body';
|
||||
const tmp = ns && ns !== 'http://www.w3.org/1999/xhtml'
|
||||
? document.createElementNS(ns, tag)
|
||||
: document.createElement(tag);
|
||||
tmp.innerHTML = html;
|
||||
const out = [];
|
||||
let child;
|
||||
while ((child = tmp.firstChild)) out.push(tmp.removeChild(child));
|
||||
return out;
|
||||
}
|
||||
|
||||
class Element extends Node {
|
||||
constructor(nid) {
|
||||
super(nid);
|
||||
this._style = _styleProxy(new CSSStyleDeclaration());
|
||||
this._style = _styleProxy(new CSSStyleDeclaration(this));
|
||||
}
|
||||
// Element wrappers always back a nodeType-1 node (_wrap/_wrapEl only build an
|
||||
// Element for element nodes, and node ids are never freed-and-reused), so this
|
||||
@@ -1398,18 +1517,27 @@ class Element extends Node {
|
||||
setAttribute(n, v) {
|
||||
n = _htmlAttrName(this, n);
|
||||
const popoverPrev = (n === "popover") ? this.popover : undefined;
|
||||
_dom("set_attribute", this._nid, n + "\0" + String(v));
|
||||
const value = String(v);
|
||||
_dom("set_attribute", this._nid, n + "\0" + value);
|
||||
if (n === "style") this._style._replaceFromAttribute(value);
|
||||
if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
|
||||
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
|
||||
}
|
||||
setAttributeNS(ns, n, v) {
|
||||
ns = ns == null || ns === '' ? '' : String(ns);
|
||||
n = String(n);
|
||||
const value = String(v);
|
||||
_ns_validateQualifiedName(ns, n);
|
||||
_dom("set_attribute_ns", this._nid, ns + "\0" + n + "\0" + String(v));
|
||||
_dom("set_attribute_ns", this._nid, ns + "\0" + n + "\0" + value);
|
||||
if (ns === "" && n === "style") this._style._replaceFromAttribute(value);
|
||||
}
|
||||
removeAttribute(n) { n = _htmlAttrName(this, n); const popoverPrev = (n === "popover") ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (n === "style") this._style._replaceFromAttribute(""); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); }
|
||||
removeAttributeNS(ns, n) {
|
||||
ns = String(ns == null ? "" : ns);
|
||||
n = String(n);
|
||||
_dom("remove_attribute_ns", this._nid, ns + "\0" + n);
|
||||
if (ns === "" && n === "style") this._style._replaceFromAttribute("");
|
||||
}
|
||||
removeAttribute(n) { n = _htmlAttrName(this, n); const popoverPrev = (n === "popover") ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); }
|
||||
removeAttributeNS(ns, n) { _dom("remove_attribute_ns", this._nid, String(ns == null ? "" : ns) + "\0" + String(n)); }
|
||||
hasAttribute(n) { return this.getAttribute(n) !== null; }
|
||||
hasAttributes() { return true; } // Simplified
|
||||
getAttributeNames() { return _domParse("attribute_names", this._nid) || []; }
|
||||
@@ -1484,20 +1612,33 @@ class Element extends Node {
|
||||
return null;
|
||||
}
|
||||
insertAdjacentHTML(position, html) {
|
||||
// Position is matched ASCII-case-insensitively; an unknown value throws
|
||||
// SyntaxError (both were silent no-ops before). Sibling insertions parse
|
||||
// against the parent's context, child insertions against this element, so
|
||||
// table/select fragments keep the right parsing context (_parseHTMLFragment).
|
||||
const pos = String(position).toLowerCase();
|
||||
const parent = this.parentNode;
|
||||
switch (position) {
|
||||
const context = (pos === 'beforebegin' || pos === 'afterend') ? parent : this;
|
||||
switch (pos) {
|
||||
case 'beforebegin':
|
||||
if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], this); }
|
||||
if (parent) for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, this);
|
||||
break;
|
||||
case 'afterbegin':
|
||||
{ const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const first = this.firstChild; for (let i = children.length - 1; i >= 0; i--) this.insertBefore(children[i], first); }
|
||||
case 'afterbegin': {
|
||||
const first = this.firstChild;
|
||||
for (const n of _parseHTMLFragment(html, context)) this.insertBefore(n, first);
|
||||
break;
|
||||
}
|
||||
case 'beforeend':
|
||||
{ const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) this.appendChild(children[i]); }
|
||||
for (const n of _parseHTMLFragment(html, context)) this.appendChild(n);
|
||||
break;
|
||||
case 'afterend':
|
||||
if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const next = this.nextSibling; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], next); }
|
||||
if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, next); }
|
||||
break;
|
||||
default:
|
||||
throw new DOMException(
|
||||
"Failed to execute 'insertAdjacentHTML' on 'Element': The value provided ('" + position + "') is not one of 'beforeBegin', 'afterBegin', 'beforeEnd', or 'afterEnd'.",
|
||||
"SyntaxError"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Like insertAdjacentHTML but inserts a Text node instead of parsing markup,
|
||||
@@ -4185,13 +4326,13 @@ if (typeof Response === 'undefined') {
|
||||
}
|
||||
|
||||
if (!Element.prototype.replaceWith) {
|
||||
// _convertNodes turns any non-node argument (numbers, booleans, null, …) into
|
||||
// a Text node via String(n), matching the spec and append()/prepend(); the
|
||||
// old `typeof n === 'string'` check corrupted insert_before for other types.
|
||||
Element.prototype.replaceWith = function(...nodes) {
|
||||
const parent = this.parentNode;
|
||||
if (!parent) return;
|
||||
for (const n of nodes) {
|
||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
|
||||
else parent.insertBefore(n, this);
|
||||
}
|
||||
for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
|
||||
parent.removeChild(this);
|
||||
};
|
||||
_markNative(Element.prototype.replaceWith);
|
||||
@@ -4200,10 +4341,7 @@ if (!Element.prototype.before) {
|
||||
Element.prototype.before = function(...nodes) {
|
||||
const parent = this.parentNode;
|
||||
if (!parent) return;
|
||||
for (const n of nodes) {
|
||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
|
||||
else parent.insertBefore(n, this);
|
||||
}
|
||||
for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
|
||||
};
|
||||
_markNative(Element.prototype.before);
|
||||
}
|
||||
@@ -4212,10 +4350,7 @@ if (!Element.prototype.after) {
|
||||
const parent = this.parentNode;
|
||||
if (!parent) return;
|
||||
const ref = this.nextSibling;
|
||||
for (const n of nodes) {
|
||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), ref);
|
||||
else parent.insertBefore(n, ref);
|
||||
}
|
||||
for (const n of _convertNodes(nodes)) parent.insertBefore(n, ref);
|
||||
};
|
||||
_markNative(Element.prototype.after);
|
||||
}
|
||||
@@ -5457,11 +5592,16 @@ globalThis.XMLSerializer = class XMLSerializer {
|
||||
};
|
||||
globalThis.performance = globalThis.performance || {
|
||||
now: (function() {
|
||||
var _lastMs = -1, _sub = 0;
|
||||
// Monotonically non-decreasing: return the wall-clock offset, but never a
|
||||
// value below the last one. Equal readings are allowed, and avoiding a
|
||||
// synthetic per-call increment keeps tight loops from advancing the clock
|
||||
// faster than real elapsed time.
|
||||
var _last = -Infinity;
|
||||
return function() {
|
||||
var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
|
||||
if (ms !== _lastMs) { _lastMs = ms; _sub = 0; } else { _sub += 0.1; }
|
||||
return ms + _sub;
|
||||
if (ms < _last) return _last;
|
||||
_last = ms;
|
||||
return _last;
|
||||
};
|
||||
})(),
|
||||
mark(){}, measure(){},
|
||||
@@ -5710,7 +5850,10 @@ globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWX
|
||||
const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL"
|
||||
let idx = 0;
|
||||
const resolveOrFallback = (url) => {
|
||||
if (url === null || url === undefined) return undefined;
|
||||
// A missing url (pushState/replaceState called with < 3 args) keeps the
|
||||
// current document URL per the HTML spec — capture it so the entry does not
|
||||
// reset location back to the original document URL.
|
||||
if (url === null || url === undefined) return __currentUrl();
|
||||
try { return new URL(String(url), __currentUrl()).href; } catch (e) { return String(url); }
|
||||
};
|
||||
const applyVirtual = () => {
|
||||
|
||||
@@ -421,7 +421,16 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
||||
dom.detach(child);
|
||||
}
|
||||
if !arg2.is_empty() {
|
||||
let fragment = obscura_dom::parse_fragment(&arg2);
|
||||
let context_name = dom
|
||||
.with_node(target, |node| match &node.data {
|
||||
NodeData::Element { name, .. } => Some(name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten();
|
||||
let fragment = match context_name {
|
||||
Some(name) => obscura_dom::parse_fragment_with_context(&arg2, name),
|
||||
None => obscura_dom::parse_fragment(&arg2),
|
||||
};
|
||||
let import_root = fragment.find_body_or_root();
|
||||
dom.import_children_from(target, &fragment, import_root);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1317,293 @@ mod tests {
|
||||
rt
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn string_timeout_handler_executes_in_global_scope() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.evaluate(
|
||||
"var __timerValue='pending'; setTimeout('__timerValue=\"done\"', 0)",
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("globalThis.__timerValue").unwrap(),
|
||||
serde_json::json!("done")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn string_interval_handler_repeats_and_can_clear_itself() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.evaluate("globalThis.__ticks=0").unwrap();
|
||||
rt.evaluate(
|
||||
"globalThis.__timerId=setInterval('__ticks++;if(__ticks===2)clearInterval(__timerId)',1)",
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("globalThis.__ticks").unwrap(),
|
||||
serde_json::json!(2.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn performance_now_is_monotonic_under_bursty_calls() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
// Hammer performance.now() so many calls land in the same millisecond and
|
||||
// the wall clock rolls over repeatedly; the value must never go backwards.
|
||||
let violations = rt
|
||||
.evaluate(
|
||||
"(function(){var prev=-Infinity, bad=0; for(var i=0;i<500000;i++){var t=performance.now(); if(t<prev) bad++; prev=t;} return bad;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(violations.as_f64(), Some(0.0), "performance.now() went backwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn performance_now_does_not_outrun_elapsed_time() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let lead = rt
|
||||
.evaluate(
|
||||
"(function(){for(var i=0;i<500000;i++)performance.now(); return performance.now()-(Date.now()-performance.timeOrigin);})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
lead.as_f64().unwrap() <= 1.0,
|
||||
"performance.now() advanced ahead of elapsed time: {lead}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn childnode_helpers_coerce_non_string_primitives_to_text() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="p"><span id="t">x</span></div></body></html>"#);
|
||||
let before = rt
|
||||
.evaluate("(function(){var t=document.getElementById('t'); t.before(5); return t.previousSibling ? t.previousSibling.textContent : 'NULL';})()")
|
||||
.unwrap();
|
||||
assert_eq!(before, serde_json::json!("5"));
|
||||
let after = rt
|
||||
.evaluate("(function(){var t=document.getElementById('t'); t.after(true); return t.nextSibling ? t.nextSibling.textContent : 'NULL';})()")
|
||||
.unwrap();
|
||||
assert_eq!(after, serde_json::json!("true"));
|
||||
let replaced = rt
|
||||
.evaluate("(function(){var t=document.getElementById('t'); t.replaceWith(42); return document.getElementById('p').textContent;})()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
replaced.as_str().unwrap().contains("42"),
|
||||
"replaceWith(42) should leave text '42': {replaced}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_state_without_url_preserves_current_location() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let path = rt
|
||||
.evaluate(
|
||||
"(function(){history.pushState({}, '', '/dashboard'); history.replaceState({scroll:1}); return location.pathname;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, serde_json::json!("/dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_state_without_url_preserves_current_location() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let path = rt
|
||||
.evaluate(
|
||||
"(function(){history.pushState({}, '', '/a'); history.pushState({b:1}); return location.pathname;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, serde_json::json!("/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_attribute_parses_into_style_object() {
|
||||
// Inline styles present in the parsed HTML must be visible via el.style.*
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><div id="d" style="color: red; display: none">hi</div></body></html>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
rt.evaluate("document.getElementById('d').style.color").unwrap(),
|
||||
serde_json::json!("red")
|
||||
);
|
||||
assert_eq!(
|
||||
rt.evaluate("document.getElementById('d').style.display").unwrap(),
|
||||
serde_json::json!("none")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_style_attribute_updates_style_object() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d">hi</div></body></html>"#);
|
||||
let margin = rt
|
||||
.evaluate(
|
||||
"(function(){var e=document.getElementById('d'); e.setAttribute('style','margin: 5px'); return e.style.margin;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(margin, serde_json::json!("5px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_style_property_updates_the_attribute_and_serialization() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d">hi</div></body></html>"#);
|
||||
let attr = rt
|
||||
.evaluate(
|
||||
"(function(){var e=document.getElementById('d'); e.style.color='blue'; return e.getAttribute('style');})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attr, serde_json::json!("color: blue;"));
|
||||
let html = rt
|
||||
.evaluate("document.getElementById('d').outerHTML")
|
||||
.unwrap();
|
||||
assert!(
|
||||
html.as_str().unwrap().contains("color: blue"),
|
||||
"outerHTML should carry the style set via el.style: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_object_reflects_external_attribute_change() {
|
||||
// A later setAttribute('style', …) must supersede an earlier value read
|
||||
// through el.style (the declaration re-syncs from the attribute).
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><div id="d" style="color: red">hi</div></body></html>"#,
|
||||
);
|
||||
let color = rt
|
||||
.evaluate(
|
||||
"(function(){var e=document.getElementById('d'); e.style.color; e.setAttribute('style','color: green'); return e.style.color;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(color, serde_json::json!("green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_deep_preserves_context_sensitive_elements() {
|
||||
// A <tr> is not a valid child of <div>, so cloning through a throwaway
|
||||
// <div>.innerHTML dropped it and returned null. A structural clone keeps it.
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let tag = rt
|
||||
.evaluate("(document.createElement('tr').cloneNode(true) || {}).tagName || 'NULL'")
|
||||
.unwrap();
|
||||
assert_eq!(tag, serde_json::json!("TR"));
|
||||
let td = rt
|
||||
.evaluate("(document.createElement('td').cloneNode(true) || {}).tagName || 'NULL'")
|
||||
.unwrap();
|
||||
assert_eq!(td, serde_json::json!("TD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_deep_copies_children_and_attributes() {
|
||||
let mut rt = setup_runtime(r#"<html><body><ul id="l"><li class="a">one</li><li class="b">two</li></ul></body></html>"#);
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var c=document.getElementById('l').cloneNode(true); return c.children.length + '|' + c.children[0].className + '|' + c.children[1].textContent;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("2|a|two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_deep_preserves_table_rows() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><table id="t"><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
|
||||
);
|
||||
// Navigate the detached clone directly (querySelector does not traverse
|
||||
// detached subtrees). tbody > tr > (td, td).
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var tb=document.querySelector('#t tbody').cloneNode(true); var tr=tb.children[0]; return tr.tagName + '|' + tr.children.length + '|' + tr.children[1].textContent;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("TR|2|2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_shallow_copies_attributes_without_children() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d" data-x="7"><span>kid</span></div></body></html>"#);
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var c=document.getElementById('d').cloneNode(false); return c.getAttribute('data-x') + '|' + c.childNodes.length;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("7|0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_copies_js_assigned_inline_styles() {
|
||||
let mut rt = setup_runtime("<html><body><div id='d'></div></body></html>");
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var d=document.getElementById('d');d.style.color='red';d.style.fontSize='12px';var c=d.cloneNode(false);return c.style.color+'|'+c.style.fontSize+'|'+c.style.cssText;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("red|12px|color: red; font-size: 12px;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_node_deep_copies_template_content() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var t=document.createElement('template');t.content.appendChild(document.createElement('option')).textContent='choice';var c=t.cloneNode(true);return c.content.childNodes.length+'|'+c.content.firstChild.tagName+'|'+c.content.firstChild.textContent;})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("1|OPTION|choice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_adjacent_html_parses_table_fragments() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><table id="t"><tbody id="tb"></tbody></table></body></html>"#,
|
||||
);
|
||||
let out = rt
|
||||
.evaluate("(function(){var tb=document.getElementById('tb'); tb.insertAdjacentHTML('beforeend','<tr><td>1</td><td>2</td></tr>'); var tr=tb.firstElementChild; return tr ? (tr.tagName+':'+tr.children.length) : 'NULL';})()")
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("TR:2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_adjacent_html_position_is_case_insensitive() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="host"><span>base</span></div></body></html>"#);
|
||||
let out = rt
|
||||
.evaluate("(function(){var h=document.getElementById('host'); h.insertAdjacentHTML('BeforeEnd','<b>x</b>'); return h.lastElementChild ? h.lastElementChild.tagName : 'NULL';})()")
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_adjacent_html_rejects_invalid_position() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="host"></div></body></html>"#);
|
||||
let out = rt
|
||||
.evaluate("(function(){var h=document.getElementById('host'); try { h.insertAdjacentHTML('nope','<b>x</b>'); return 'no-throw'; } catch(e){ return e.name; }})()")
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("SyntaxError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_adjacent_html_keeps_leading_comments_in_table_contexts() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><table><tbody id="tb"><tr id="row"></tr></tbody></table></body></html>"#,
|
||||
);
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','<!--m--><tr><td>v</td></tr>');var row=document.getElementById('row');row.insertAdjacentHTML('beforeend','<!--n--><td>x</td>');return Array.from(tb.childNodes).map(function(n){return n.nodeName}).join('|')+';'+Array.from(row.childNodes).map(function(n){return n.nodeName}).join('|');})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("TR|#comment|TR;#comment|TD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_adjacent_html_uses_the_insertion_element_as_context() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><div id="d"></div><table id="table"><tbody id="tb"></tbody></table></body></html>"#,
|
||||
);
|
||||
let out = rt
|
||||
.evaluate(
|
||||
"(function(){var d=document.getElementById('d');d.insertAdjacentHTML('beforeend','<tr><td>v</td></tr>');var table=document.getElementById('table');table.insertAdjacentHTML('beforeend','<tr><td>x</td></tr>');var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','<tr><td>y</td></tr>tail');return d.firstChild.nodeName+':'+d.textContent+';'+table.lastElementChild.tagName+';'+Array.from(tb.childNodes).map(function(n){return n.nodeName+(n.data?':'+n.data:'')}).join('|');})()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out, serde_json::json!("#text:v;TBODY;TR|#text:tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_attribute_ns_is_retrievable_by_namespace_and_local_name() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
|
||||
@@ -57,6 +57,39 @@ impl StealthHttpClient {
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(wreq::redirect::Policy::none());
|
||||
|
||||
// Honor SSL_CERT_FILE / SSL_CERT_DIR in the stealth client too.
|
||||
//
|
||||
// `client.rs` (the reqwest path) already reads these via `configured_root_paths()` and
|
||||
// feeds `add_root_certificate`, so a private CA works there. This client did not, which
|
||||
// made the *better-fingerprinted* transport the only one unable to reach hosts behind a
|
||||
// private/national CA (measured against a Brazilian government portal whose leaf is
|
||||
// issued by an ICP-Brasil intermediate: `--stealth` failed with CERTIFICATE_VERIFY_FAILED
|
||||
// while the reqwest path, with SSL_CERT_FILE set, completed the handshake).
|
||||
//
|
||||
// Two deliberate constraints:
|
||||
//
|
||||
// 1. `tls_cert_store` is used, NOT `tls_options`. `emulation()` overwrites `tls_options`
|
||||
// wholesale ("This will overwrite the existing configuration"), so setting TLS options
|
||||
// here would silently discard the Chrome fingerprint — the whole point of this client.
|
||||
// `tls_cert_store` is a separate field on the config and composes with emulation.
|
||||
//
|
||||
// 2. Opt-in only. Supplying a store REPLACES the webpki roots (see `set_cert_store` in
|
||||
// `tls/conn/ext.rs`), it does not add to them. Applying it unconditionally would break
|
||||
// every ordinary site whenever the bundle is incomplete. With neither variable set,
|
||||
// behaviour is byte-for-byte what it was before.
|
||||
if std::env::var_os("SSL_CERT_FILE").is_some()
|
||||
|| std::env::var_os("SSL_CERT_DIR").is_some()
|
||||
{
|
||||
match wreq::tls::trust::CertStore::builder().set_default_paths().build() {
|
||||
Ok(store) => builder = builder.tls_cert_store(store),
|
||||
Err(error) => tracing::warn!(
|
||||
%error,
|
||||
"SSL_CERT_FILE/SSL_CERT_DIR set but the certificate store failed to build; \
|
||||
continuing with the default roots"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
if let Ok(p) = wreq::Proxy::all(proxy) {
|
||||
builder = builder.proxy(p);
|
||||
|
||||
Reference in new issue
Block a user