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
+778
-94
No files matched your search
@@ -236,6 +236,25 @@ pub async fn handle(
|
|||||||
let _ = page.evaluate(&code);
|
let _ = page.evaluate(&code);
|
||||||
Ok(json!({}))
|
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" => {
|
"setFileInputFiles" => {
|
||||||
// Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles
|
// Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles
|
||||||
// drive an <input type=file> through this CDP call (issue #359). Read
|
// 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
|
// A hostile or heavy page can nest nodes tens of thousands deep (trivially
|
||||||
// scriptable, and html5ever puts no cap on generic nesting). DOM.getDocument
|
// 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
|
// 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 mod serialize;
|
||||||
|
|
||||||
pub use tree::{Attribute, DomTree, Node, NodeData, NodeId};
|
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,16 +580,21 @@ impl DomTree {
|
|||||||
// Fast path: a bare "#id" selector resolves through the id index in O(1)
|
// 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
|
// 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 tree order per id, which is exactly what the full scan would return.
|
||||||
if let Some(id) = simple_id_selector(selector) {
|
// In quirks mode `#id` matches ASCII-case-insensitively, but the id
|
||||||
match self.get_element_by_id(id) {
|
// index is keyed on the exact-case id, so skip the fast path and let the
|
||||||
// querySelector matches strict descendants of root only, so the
|
// selector engine (below) do the case-insensitive match.
|
||||||
// indexed element must have root among its ancestors.
|
if !self.is_quirks() {
|
||||||
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
|
if let Some(id) = simple_id_selector(selector) {
|
||||||
// Index miss or stale entry (detached node) — fall through to full scan.
|
match self.get_element_by_id(id) {
|
||||||
// The id_index is best-effort: it only registers nodes at creation time
|
// querySelector matches strict descendants of root only, so the
|
||||||
// and does not update on reparent, so it can point to a detached clone
|
// indexed element must have root among its ancestors.
|
||||||
// while the live node (with the same id) is elsewhere in the tree.
|
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
|
||||||
_ => {}
|
// Index miss or stale entry (detached node) — fall through to full scan.
|
||||||
|
// The id_index is best-effort: it only registers nodes at creation time
|
||||||
|
// and does not update on reparent, so it can point to a detached clone
|
||||||
|
// while the live node (with the same id) is elsewhere in the tree.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let selector_list = parse_selector(selector)?;
|
let selector_list = parse_selector(selector)?;
|
||||||
@@ -598,7 +603,7 @@ impl DomTree {
|
|||||||
MatchingMode::Normal,
|
MatchingMode::Normal,
|
||||||
None,
|
None,
|
||||||
&mut caches,
|
&mut caches,
|
||||||
QuirksMode::NoQuirks,
|
self.selector_quirks_mode(),
|
||||||
NeedsSelectorFlags::No,
|
NeedsSelectorFlags::No,
|
||||||
MatchingForInvalidation::No,
|
MatchingForInvalidation::No,
|
||||||
);
|
);
|
||||||
@@ -619,6 +624,16 @@ impl DomTree {
|
|||||||
Ok(None)
|
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> {
|
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
|
||||||
let selector_list = parse_selector(selector)?;
|
let selector_list = parse_selector(selector)?;
|
||||||
let mut caches = selectors::context::SelectorCaches::default();
|
let mut caches = selectors::context::SelectorCaches::default();
|
||||||
@@ -626,7 +641,7 @@ impl DomTree {
|
|||||||
MatchingMode::Normal,
|
MatchingMode::Normal,
|
||||||
None,
|
None,
|
||||||
&mut caches,
|
&mut caches,
|
||||||
QuirksMode::NoQuirks,
|
self.selector_quirks_mode(),
|
||||||
NeedsSelectorFlags::No,
|
NeedsSelectorFlags::No,
|
||||||
MatchingForInvalidation::No,
|
MatchingForInvalidation::No,
|
||||||
);
|
);
|
||||||
@@ -729,6 +744,34 @@ mod tests {
|
|||||||
assert!(result.is_none());
|
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]
|
#[test]
|
||||||
fn test_query_selector_complex() {
|
fn test_query_selector_complex() {
|
||||||
let tree = parse_html(
|
let tree = parse_html(
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ pub(crate) struct DomTreeInner {
|
|||||||
pub(crate) free_list: Vec<u32>,
|
pub(crate) free_list: Vec<u32>,
|
||||||
pub(crate) document: NodeId,
|
pub(crate) document: NodeId,
|
||||||
pub(crate) id_index: HashMap<String, 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 {
|
impl DomTree {
|
||||||
@@ -226,6 +229,7 @@ impl DomTree {
|
|||||||
free_list: Vec::new(),
|
free_list: Vec::new(),
|
||||||
document: NodeId(0),
|
document: NodeId(0),
|
||||||
id_index: HashMap::new(),
|
id_index: HashMap::new(),
|
||||||
|
quirks: false,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +238,17 @@ impl DomTree {
|
|||||||
self.inner.borrow().document
|
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> {
|
pub(crate) fn borrow_inner(&self) -> std::cell::Ref<'_, DomTreeInner> {
|
||||||
self.inner.borrow()
|
self.inner.borrow()
|
||||||
}
|
}
|
||||||
@@ -533,12 +548,20 @@ impl DomTree {
|
|||||||
inner.id_index.remove(&id_str);
|
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 {
|
for desc_id in descendants {
|
||||||
inner.nodes[desc_id.index()] = None;
|
if matches!(inner.nodes.get(desc_id.index()), Some(Some(_))) {
|
||||||
inner.free_list.push(desc_id.0);
|
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);
|
||||||
}
|
}
|
||||||
inner.nodes[node_id.index()] = None;
|
|
||||||
inner.free_list.push(node_id.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn children(&self, node_id: NodeId) -> Vec<NodeId> {
|
pub fn children(&self, node_id: NodeId) -> Vec<NodeId> {
|
||||||
@@ -996,6 +1019,30 @@ mod tests {
|
|||||||
assert!(node.is_document());
|
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]
|
#[test]
|
||||||
fn test_append_child() {
|
fn test_append_child() {
|
||||||
let tree = DomTree::new();
|
let tree = DomTree::new();
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ impl TreeSink for DomTree {
|
|||||||
x == y
|
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 {
|
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 {
|
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"));
|
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();
|
let tree = DomTree::new();
|
||||||
parse_fragment(tree, ParseOpts::default(), context_name, vec![])
|
parse_fragment(tree, ParseOpts::default(), context_name, vec![])
|
||||||
.from_utf8()
|
.from_utf8()
|
||||||
|
|||||||
Vendored
+215
-72
@@ -24,7 +24,7 @@
|
|||||||
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
|
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
|
||||||
'_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
|
'_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
|
||||||
'_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
|
'_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
|
||||||
'_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl',
|
'_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_elementClassFor', '_wrap', '_wrapEl',
|
||||||
'_resolveUrl', '_registerIframe', '_base64ToUint8Array',
|
'_resolveUrl', '_registerIframe', '_base64ToUint8Array',
|
||||||
'_bodyToUint8Array', '_arrayBufferFromBytes',
|
'_bodyToUint8Array', '_arrayBufferFromBytes',
|
||||||
'_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp',
|
'_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp',
|
||||||
@@ -36,6 +36,18 @@
|
|||||||
'_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset',
|
'_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset',
|
||||||
'_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase',
|
'_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase',
|
||||||
'_makeListenerBox',
|
'_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 };
|
var _desc = { value: undefined, writable: true, enumerable: false, configurable: true };
|
||||||
for (var _i = 0; _i < _names.length; _i++) {
|
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);
|
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) => {
|
globalThis.setTimeout = (fn, delay = 0, ...args) => {
|
||||||
if (typeof fn !== "function") return ++_tid;
|
const f = _coerceTimerFn(fn);
|
||||||
|
if (f === null) return ++_tid;
|
||||||
const id = ++_tid;
|
const id = ++_tid;
|
||||||
_scheduleAfter(delay, () => {
|
_scheduleAfter(delay, () => {
|
||||||
if (_clearedTimers.has(id)) return;
|
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;
|
return id;
|
||||||
};
|
};
|
||||||
@@ -469,12 +493,13 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => {
|
|||||||
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
|
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
|
||||||
|
|
||||||
globalThis.setInterval = (fn, delay = 0, ...args) => {
|
globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||||
if (typeof fn !== "function") return ++_tid;
|
const f = _coerceTimerFn(fn);
|
||||||
|
if (f === null) return ++_tid;
|
||||||
const id = ++_tid;
|
const id = ++_tid;
|
||||||
_intervals.add(id);
|
_intervals.add(id);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!_intervals.has(id)) return;
|
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;
|
if (!_intervals.has(id)) return;
|
||||||
_scheduleAfter(delay, tick);
|
_scheduleAfter(delay, tick);
|
||||||
};
|
};
|
||||||
@@ -564,35 +589,75 @@ const _CSS_PROPERTY_NAMES = [
|
|||||||
];
|
];
|
||||||
const _CSS_PROP_SET = new Set(_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 {
|
class CSSStyleDeclaration {
|
||||||
constructor() {
|
constructor(owner) {
|
||||||
// Non-enumerable so it never leaks through the proxy's own-key traps.
|
// Non-enumerable so they never leak through the proxy's own-key traps.
|
||||||
Object.defineProperty(this, "_props", { value: {}, writable: true, enumerable: false, configurable: true });
|
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
|
// 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
|
// camelCase IDL access (el.style.fontSize) onto the dashed key (font-size), so
|
||||||
// getPropertyValue('font-size') and el.style.fontSize stay in sync.
|
// getPropertyValue('font-size') and el.style.fontSize stay in sync.
|
||||||
setProperty(name, value) {
|
setProperty(name, value) {
|
||||||
|
this._pull();
|
||||||
const k = _cssCamelToKebab(String(name));
|
const k = _cssCamelToKebab(String(name));
|
||||||
if (value === "" || value == null) { delete this._props[k]; return; }
|
if (value === "" || value == null) delete this._props[k];
|
||||||
this._props[k] = String(value);
|
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 || ""; }
|
removeProperty(name) { this._pull(); const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; this._push(); return old || ""; }
|
||||||
getPropertyValue(name) { return this._props[_cssCamelToKebab(String(name))] || ""; }
|
getPropertyValue(name) { this._pull(); return this._props[_cssCamelToKebab(String(name))] || ""; }
|
||||||
getPropertyPriority() { return ""; }
|
getPropertyPriority() { return ""; }
|
||||||
get cssText() {
|
get cssText() { this._pull(); return _serializeCss(this._props); }
|
||||||
const e = Object.entries(this._props);
|
|
||||||
return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : "";
|
|
||||||
}
|
|
||||||
set cssText(v) {
|
set cssText(v) {
|
||||||
for (const k in this._props) delete this._props[k];
|
_parseCssInto(this._props, v);
|
||||||
if (v) String(v).split(";").forEach((p) => {
|
this._push();
|
||||||
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; }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
get length() { return Object.keys(this._props).length; }
|
get length() { this._pull(); return Object.keys(this._props).length; }
|
||||||
item(i) { return Object.keys(this._props)[i] || ""; }
|
item(i) { this._pull(); return Object.keys(this._props)[i] || ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const _styleProxy = (decl) => new Proxy(decl, {
|
const _styleProxy = (decl) => new Proxy(decl, {
|
||||||
@@ -603,6 +668,7 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
|||||||
},
|
},
|
||||||
set(t, p, v) {
|
set(t, p, v) {
|
||||||
if (typeof p === "symbol") { t[p] = v; return true; }
|
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 (p === "cssText") { t.cssText = v; return true; }
|
||||||
if (/^\d+$/.test(p) || p in Object.getPrototypeOf(t)) return true;
|
if (/^\d+$/.test(p) || p in Object.getPrototypeOf(t)) return true;
|
||||||
t.setProperty(p, v);
|
t.setProperty(p, v);
|
||||||
@@ -611,11 +677,13 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
|||||||
has(t, p) {
|
has(t, p) {
|
||||||
if (typeof p !== "string") return Reflect.has(t, p);
|
if (typeof p !== "string") return Reflect.has(t, p);
|
||||||
if (p in Object.getPrototypeOf(t)) return true;
|
if (p in Object.getPrototypeOf(t)) return true;
|
||||||
|
t._pull();
|
||||||
if (_cssCamelToKebab(p) in t._props) return true;
|
if (_cssCamelToKebab(p) in t._props) return true;
|
||||||
if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true;
|
if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true;
|
||||||
return /^\d+$/.test(p) && +p < t.length;
|
return /^\d+$/.test(p) && +p < t.length;
|
||||||
},
|
},
|
||||||
ownKeys(t) {
|
ownKeys(t) {
|
||||||
|
t._pull();
|
||||||
const keys = [];
|
const keys = [];
|
||||||
const n = t.length;
|
const n = t.length;
|
||||||
for (let i = 0; i < n; i++) keys.push(String(i));
|
for (let i = 0; i < n; i++) keys.push(String(i));
|
||||||
@@ -626,6 +694,7 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
|||||||
},
|
},
|
||||||
getOwnPropertyDescriptor(t, p) {
|
getOwnPropertyDescriptor(t, p) {
|
||||||
if (typeof p !== "string") return Reflect.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 (/^\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))) {
|
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 };
|
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 {
|
class Node {
|
||||||
static ELEMENT_NODE = 1;
|
static ELEMENT_NODE = 1;
|
||||||
static ATTRIBUTE_NODE = 2;
|
static ATTRIBUTE_NODE = 2;
|
||||||
@@ -824,32 +920,38 @@ class Node {
|
|||||||
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
|
contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; }
|
||||||
hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; }
|
hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; }
|
||||||
cloneNode(deep) {
|
cloneNode(deep) {
|
||||||
const t = this.nodeType;
|
// Clone structurally via real DOM nodes rather than round-tripping through a
|
||||||
if (t === 1) {
|
// throwaway <div>.innerHTML: the fragment parser discards elements that are
|
||||||
if (deep) {
|
// not valid children of <div> (<tr>, <td>, <option>, …), so the old path
|
||||||
const wrapper = document.createElement('div');
|
// returned null for them and lost JS-set inline styles. Building each node
|
||||||
wrapper.innerHTML = _domParse("outer_html", this._nid) || "";
|
// directly with createElement(NS) + attribute copy avoids any parsing
|
||||||
const clone = wrapper.firstChild;
|
// context, and an explicit stack keeps a deep subtree from overflowing the
|
||||||
return clone;
|
// JS stack (issue #490).
|
||||||
}
|
const root = _shallowCloneNode(this);
|
||||||
const el = document.createElement(this.nodeName.toLowerCase());
|
if (!deep || !root) return root;
|
||||||
const html = _domParse("outer_html", this._nid) || "";
|
const stack = [[this, root]];
|
||||||
const attrMatch = html.match(/^<[a-zA-Z][^\s>]*([\s\S]*?)>/);
|
while (stack.length) {
|
||||||
if (attrMatch && attrMatch[1]) {
|
const [src, dst] = stack.pop();
|
||||||
const attrStr = attrMatch[1].trim();
|
// A <template>'s children hang off its content fragment, not childNodes,
|
||||||
const re = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
// so clone them into the clone's fragment. Gated on the tag name because
|
||||||
let m;
|
// .content means something else on other elements (e.g. <meta>).
|
||||||
while ((m = re.exec(attrStr)) !== null) {
|
if (src.localName === 'template' && dst.localName === 'template') {
|
||||||
const name = m[1];
|
const sc = src.content, dc = dst.content;
|
||||||
const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || "";
|
if (sc && dc && sc.childNodes) {
|
||||||
if (name !== this.nodeName.toLowerCase()) el.setAttribute(name, val);
|
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;
|
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]); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (t === 3) return document.createTextNode(this.textContent);
|
return root;
|
||||||
if (t === 8) return document.createComment(this.nodeValue || "");
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
compareDocumentPosition(other) {
|
compareDocumentPosition(other) {
|
||||||
if (!other) return 0;
|
if (!other) return 0;
|
||||||
@@ -1227,10 +1329,27 @@ function _isSubmitButton(el) {
|
|||||||
return false;
|
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 {
|
class Element extends Node {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
super(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 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
|
// 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) {
|
setAttribute(n, v) {
|
||||||
n = _htmlAttrName(this, n);
|
n = _htmlAttrName(this, n);
|
||||||
const popoverPrev = (n === "popover") ? this.popover : undefined;
|
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 (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
|
||||||
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
|
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
|
||||||
}
|
}
|
||||||
setAttributeNS(ns, n, v) {
|
setAttributeNS(ns, n, v) {
|
||||||
ns = ns == null || ns === '' ? '' : String(ns);
|
ns = ns == null || ns === '' ? '' : String(ns);
|
||||||
n = String(n);
|
n = String(n);
|
||||||
|
const value = String(v);
|
||||||
_ns_validateQualifiedName(ns, n);
|
_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; }
|
hasAttribute(n) { return this.getAttribute(n) !== null; }
|
||||||
hasAttributes() { return true; } // Simplified
|
hasAttributes() { return true; } // Simplified
|
||||||
getAttributeNames() { return _domParse("attribute_names", this._nid) || []; }
|
getAttributeNames() { return _domParse("attribute_names", this._nid) || []; }
|
||||||
@@ -1484,20 +1612,33 @@ class Element extends Node {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
insertAdjacentHTML(position, html) {
|
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;
|
const parent = this.parentNode;
|
||||||
switch (position) {
|
const context = (pos === 'beforebegin' || pos === 'afterend') ? parent : this;
|
||||||
|
switch (pos) {
|
||||||
case 'beforebegin':
|
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;
|
break;
|
||||||
case 'afterbegin':
|
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); }
|
const first = this.firstChild;
|
||||||
|
for (const n of _parseHTMLFragment(html, context)) this.insertBefore(n, first);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'beforeend':
|
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;
|
break;
|
||||||
case 'afterend':
|
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;
|
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,
|
// Like insertAdjacentHTML but inserts a Text node instead of parsing markup,
|
||||||
@@ -4185,13 +4326,13 @@ if (typeof Response === 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Element.prototype.replaceWith) {
|
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) {
|
Element.prototype.replaceWith = function(...nodes) {
|
||||||
const parent = this.parentNode;
|
const parent = this.parentNode;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
for (const n of nodes) {
|
for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
|
||||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
|
|
||||||
else parent.insertBefore(n, this);
|
|
||||||
}
|
|
||||||
parent.removeChild(this);
|
parent.removeChild(this);
|
||||||
};
|
};
|
||||||
_markNative(Element.prototype.replaceWith);
|
_markNative(Element.prototype.replaceWith);
|
||||||
@@ -4200,10 +4341,7 @@ if (!Element.prototype.before) {
|
|||||||
Element.prototype.before = function(...nodes) {
|
Element.prototype.before = function(...nodes) {
|
||||||
const parent = this.parentNode;
|
const parent = this.parentNode;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
for (const n of nodes) {
|
for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
|
||||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
|
|
||||||
else parent.insertBefore(n, this);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
_markNative(Element.prototype.before);
|
_markNative(Element.prototype.before);
|
||||||
}
|
}
|
||||||
@@ -4212,10 +4350,7 @@ if (!Element.prototype.after) {
|
|||||||
const parent = this.parentNode;
|
const parent = this.parentNode;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
const ref = this.nextSibling;
|
const ref = this.nextSibling;
|
||||||
for (const n of nodes) {
|
for (const n of _convertNodes(nodes)) parent.insertBefore(n, ref);
|
||||||
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), ref);
|
|
||||||
else parent.insertBefore(n, ref);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
_markNative(Element.prototype.after);
|
_markNative(Element.prototype.after);
|
||||||
}
|
}
|
||||||
@@ -5457,11 +5592,16 @@ globalThis.XMLSerializer = class XMLSerializer {
|
|||||||
};
|
};
|
||||||
globalThis.performance = globalThis.performance || {
|
globalThis.performance = globalThis.performance || {
|
||||||
now: (function() {
|
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() {
|
return function() {
|
||||||
var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
|
var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
|
||||||
if (ms !== _lastMs) { _lastMs = ms; _sub = 0; } else { _sub += 0.1; }
|
if (ms < _last) return _last;
|
||||||
return ms + _sub;
|
_last = ms;
|
||||||
|
return _last;
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
mark(){}, measure(){},
|
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"
|
const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL"
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
const resolveOrFallback = (url) => {
|
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); }
|
try { return new URL(String(url), __currentUrl()).href; } catch (e) { return String(url); }
|
||||||
};
|
};
|
||||||
const applyVirtual = () => {
|
const applyVirtual = () => {
|
||||||
|
|||||||
@@ -421,7 +421,16 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
|||||||
dom.detach(child);
|
dom.detach(child);
|
||||||
}
|
}
|
||||||
if !arg2.is_empty() {
|
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();
|
let import_root = fragment.find_body_or_root();
|
||||||
dom.import_children_from(target, &fragment, import_root);
|
dom.import_children_from(target, &fragment, import_root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1317,6 +1317,293 @@ mod tests {
|
|||||||
rt
|
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]
|
#[test]
|
||||||
fn set_attribute_ns_is_retrievable_by_namespace_and_local_name() {
|
fn set_attribute_ns_is_retrievable_by_namespace_and_local_name() {
|
||||||
let mut rt = setup_runtime("<html><body></body></html>");
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
|||||||
@@ -57,6 +57,39 @@ impl StealthHttpClient {
|
|||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.redirect(wreq::redirect::Policy::none());
|
.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 Some(proxy) = proxy_url {
|
||||||
if let Ok(p) = wreq::Proxy::all(proxy) {
|
if let Ok(p) = wreq::Proxy::all(proxy) {
|
||||||
builder = builder.proxy(p);
|
builder = builder.proxy(p);
|
||||||
|
|||||||
Reference in new issue
Block a user