Worker auto-execution: Worker constructor resolves relative URLs against
location.href, fetches the script, and auto-runs it. WorkerGlobalScope is
exposed so IS_WORKER_SCOPE resolves correctly. Worker data (navigator,
locale, timezone) now arrives via postMessage within the 3s timeout.
AudioBuffer class added to globalThis with getChannelData, copyFromChannel,
copyToChannel. AudioContext.createBuffer returns a real AudioBuffer. Fixes
ReferenceError that blocked the audio fingerprint section.
Crypto class added to globalThis with randomUUID and getRandomValues on its
prototype. Fixes ReferenceError in getPlatformEstimate.
AudioContext.addEventListener was a no-op; startRendering only fired
oncomplete. Add _listeners map and dispatch to stored listeners.
getElementById and querySelector('#id') returned null for elements inserted
via replaceChild(documentFragment, el). id_index only registers nodes at
creation time; .or_insert skipped IDs on cloned fragments; None caused
query_selector_from to short-circuit the full scan.
Fix querySelector: fall through to full scan on index miss or stale entry.
Fix getElementById: verify node is in live document; fall back to attribute
selector scan if not.
contentWindow getter: return null when parentNode is null (iframe was never
inserted into any tree). Previously returning null for all disconnected
iframes broke getBehemothIframe, which caused PHANTOM_DARKNESS to fall back
to self (main window) instead of the phantom iframe. That made DOC point to
the main document, so rect and font measurements wrote fragment nodes into
the main body, and the subsequent full-DOM scan in getElementById could not
find fingerprint-data through nested fragments in time.
Limiting the null return to parentNode===null (never inserted) means
getBehemothIframe gets a valid nested contentWindow as expected. creepjs
now reports hasIframeProxy: false and 0% stealth detection.
DocumentFragment constructor: pass nid through when provided.
Range.getBoundingClientRect / getClientRects: return commonAncestor rect.
755 lines
24 KiB
Rust
755 lines
24 KiB
Rust
use cssparser::{CowRcStr, ToCss};
|
|
use html5ever::{LocalName, Namespace};
|
|
use precomputed_hash::PrecomputedHash;
|
|
use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
|
|
use selectors::bloom::BloomFilter;
|
|
use selectors::context::QuirksMode;
|
|
use selectors::matching::{
|
|
ElementSelectorFlags, MatchingContext, MatchingForInvalidation, MatchingMode,
|
|
NeedsSelectorFlags,
|
|
};
|
|
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
|
|
use selectors::{Element, OpaqueElement, SelectorList};
|
|
use selectors::visitor::SelectorVisitor;
|
|
|
|
use crate::tree::{DomTree, NodeData, NodeId};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ObscuraSelector;
|
|
|
|
impl parser::SelectorImpl for ObscuraSelector {
|
|
type ExtraMatchingData<'a> = ();
|
|
type AttrValue = CssString;
|
|
type Identifier = CssString;
|
|
type LocalName = CssLocalName;
|
|
type NamespaceUrl = CssNamespace;
|
|
type NamespacePrefix = CssString;
|
|
type BorrowedLocalName = CssLocalName;
|
|
type BorrowedNamespaceUrl = CssNamespace;
|
|
type NonTSPseudoClass = PseudoClass;
|
|
type PseudoElement = PseudoElement;
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct CssString(pub String);
|
|
|
|
impl<'a> From<&'a str> for CssString {
|
|
fn from(s: &'a str) -> Self {
|
|
CssString(s.to_string())
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for CssString {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl ToCss for CssString {
|
|
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
|
cssparser::serialize_string(&self.0, dest)
|
|
}
|
|
}
|
|
|
|
impl PrecomputedHash for CssString {
|
|
fn precomputed_hash(&self) -> u32 {
|
|
let mut h: u32 = 5381;
|
|
for b in self.0.as_bytes() {
|
|
h = h.wrapping_mul(33).wrapping_add(*b as u32);
|
|
}
|
|
h
|
|
}
|
|
}
|
|
|
|
impl Default for CssString {
|
|
fn default() -> Self {
|
|
CssString(String::new())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct CssLocalName(pub LocalName);
|
|
|
|
impl<'a> From<&'a str> for CssLocalName {
|
|
fn from(s: &'a str) -> Self {
|
|
CssLocalName(LocalName::from(s))
|
|
}
|
|
}
|
|
|
|
impl ToCss for CssLocalName {
|
|
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
|
dest.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
impl PrecomputedHash for CssLocalName {
|
|
fn precomputed_hash(&self) -> u32 {
|
|
self.0.precomputed_hash()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
|
pub struct CssNamespace(pub Namespace);
|
|
|
|
impl PrecomputedHash for CssNamespace {
|
|
fn precomputed_hash(&self) -> u32 {
|
|
self.0.precomputed_hash()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PseudoClass {
|
|
Hover,
|
|
Active,
|
|
Focus,
|
|
Enabled,
|
|
Disabled,
|
|
Checked,
|
|
}
|
|
|
|
impl parser::NonTSPseudoClass for PseudoClass {
|
|
type Impl = ObscuraSelector;
|
|
|
|
fn is_active_or_hover(&self) -> bool {
|
|
matches!(self, PseudoClass::Hover | PseudoClass::Active)
|
|
}
|
|
|
|
fn is_user_action_state(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
PseudoClass::Hover | PseudoClass::Active | PseudoClass::Focus
|
|
)
|
|
}
|
|
|
|
fn visit<V>(&self, _visitor: &mut V) -> bool
|
|
where
|
|
V: SelectorVisitor<Impl = Self::Impl>,
|
|
{
|
|
true
|
|
}
|
|
}
|
|
|
|
impl ToCss for PseudoClass {
|
|
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
|
match self {
|
|
PseudoClass::Hover => dest.write_str(":hover"),
|
|
PseudoClass::Active => dest.write_str(":active"),
|
|
PseudoClass::Focus => dest.write_str(":focus"),
|
|
PseudoClass::Enabled => dest.write_str(":enabled"),
|
|
PseudoClass::Disabled => dest.write_str(":disabled"),
|
|
PseudoClass::Checked => dest.write_str(":checked"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PseudoElement {
|
|
Before,
|
|
After,
|
|
}
|
|
|
|
impl parser::PseudoElement for PseudoElement {
|
|
type Impl = ObscuraSelector;
|
|
}
|
|
|
|
impl ToCss for PseudoElement {
|
|
fn to_css<W: std::fmt::Write>(&self, dest: &mut W) -> std::fmt::Result {
|
|
match self {
|
|
PseudoElement::Before => dest.write_str("::before"),
|
|
PseudoElement::After => dest.write_str("::after"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct ObscuraSelectorParser;
|
|
|
|
impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
|
|
type Impl = ObscuraSelector;
|
|
type Error = SelectorParseErrorKind<'i>;
|
|
|
|
fn parse_non_ts_pseudo_class(
|
|
&self,
|
|
_location: cssparser::SourceLocation,
|
|
name: CowRcStr<'i>,
|
|
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
|
|
match name.as_ref() {
|
|
"hover" => Ok(PseudoClass::Hover),
|
|
"active" => Ok(PseudoClass::Active),
|
|
"focus" => Ok(PseudoClass::Focus),
|
|
"enabled" => Ok(PseudoClass::Enabled),
|
|
"disabled" => Ok(PseudoClass::Disabled),
|
|
"checked" => Ok(PseudoClass::Checked),
|
|
_ => Err(cssparser::ParseError {
|
|
kind: cssparser::ParseErrorKind::Custom(
|
|
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
|
|
),
|
|
location: _location,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct DomElement<'a> {
|
|
pub tree: &'a DomTree,
|
|
pub node_id: NodeId,
|
|
}
|
|
|
|
impl<'a> DomElement<'a> {
|
|
pub fn new(tree: &'a DomTree, node_id: NodeId) -> Self {
|
|
DomElement { tree, node_id }
|
|
}
|
|
}
|
|
|
|
impl<'a> std::fmt::Debug for DomElement<'a> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "DomElement({:?})", self.node_id)
|
|
}
|
|
}
|
|
|
|
impl<'a> PartialEq for DomElement<'a> {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.node_id == other.node_id
|
|
}
|
|
}
|
|
|
|
impl<'a> Eq for DomElement<'a> {}
|
|
|
|
impl<'a> Element for DomElement<'a> {
|
|
type Impl = ObscuraSelector;
|
|
|
|
fn opaque(&self) -> OpaqueElement {
|
|
OpaqueElement::new(self)
|
|
}
|
|
|
|
fn parent_element(&self) -> Option<Self> {
|
|
let node = self.tree.get_node(self.node_id)?;
|
|
let parent_id = node.parent?;
|
|
let parent = self.tree.get_node(parent_id)?;
|
|
if parent.is_element() {
|
|
Some(DomElement::new(self.tree, parent_id))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn parent_node_is_shadow_root(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn containing_shadow_host(&self) -> Option<Self> {
|
|
None
|
|
}
|
|
|
|
fn pseudo_element_originating_element(&self) -> Option<Self> {
|
|
None
|
|
}
|
|
|
|
fn is_pseudo_element(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn prev_sibling_element(&self) -> Option<Self> {
|
|
let node = self.tree.get_node(self.node_id)?;
|
|
let mut current = node.prev_sibling;
|
|
while let Some(sibling_id) = current {
|
|
let sibling = self.tree.get_node(sibling_id)?;
|
|
if sibling.is_element() {
|
|
return Some(DomElement::new(self.tree, sibling_id));
|
|
}
|
|
current = sibling.prev_sibling;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn next_sibling_element(&self) -> Option<Self> {
|
|
let node = self.tree.get_node(self.node_id)?;
|
|
let mut current = node.next_sibling;
|
|
while let Some(sibling_id) = current {
|
|
let sibling = self.tree.get_node(sibling_id)?;
|
|
if sibling.is_element() {
|
|
return Some(DomElement::new(self.tree, sibling_id));
|
|
}
|
|
current = sibling.next_sibling;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn first_element_child(&self) -> Option<Self> {
|
|
let node = self.tree.get_node(self.node_id)?;
|
|
let mut current = node.first_child;
|
|
while let Some(child_id) = current {
|
|
let child = self.tree.get_node(child_id)?;
|
|
if child.is_element() {
|
|
return Some(DomElement::new(self.tree, child_id));
|
|
}
|
|
current = child.next_sibling;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_html_element_in_html_document(&self) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.as_element()
|
|
.map(|name| name.ns == ns!(html))
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_local_name(&self, local_name: &CssLocalName) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.as_element()
|
|
.map(|name| name.local == local_name.0)
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_namespace(&self, ns: &CssNamespace) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.as_element()
|
|
.map(|name| name.ns == ns.0)
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_same_type(&self, other: &Self) -> bool {
|
|
let self_name = self.tree.with_node(self.node_id, |n| {
|
|
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
|
}).flatten();
|
|
let other_name = self.tree.with_node(other.node_id, |n| {
|
|
n.as_element().map(|name| (name.local.clone(), name.ns.clone()))
|
|
}).flatten();
|
|
match (self_name, other_name) {
|
|
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn attr_matches(
|
|
&self,
|
|
ns: &NamespaceConstraint<&CssNamespace>,
|
|
local_name: &CssLocalName,
|
|
operation: &AttrSelectorOperation<&CssString>,
|
|
) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |node| {
|
|
let attrs = match node.attrs() {
|
|
Some(a) => a,
|
|
None => return false,
|
|
};
|
|
attrs.iter().any(|attr| {
|
|
let ns_match = match ns {
|
|
NamespaceConstraint::Any => true,
|
|
NamespaceConstraint::Specific(expected_ns) => attr.name.ns == expected_ns.0,
|
|
};
|
|
if !ns_match || attr.name.local != local_name.0 {
|
|
return false;
|
|
}
|
|
operation.eval_str(&attr.value)
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_attr_in_no_namespace(&self, local_name: &CssLocalName) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |node| {
|
|
node.attrs()
|
|
.map(|attrs| {
|
|
attrs.iter().any(|a| {
|
|
a.name.ns == html5ever::ns!()
|
|
&& a.name.local == local_name.0
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn match_non_ts_pseudo_class(
|
|
&self,
|
|
_pc: &PseudoClass,
|
|
_context: &mut MatchingContext<'_, Self::Impl>,
|
|
) -> bool {
|
|
false
|
|
}
|
|
|
|
fn match_pseudo_element(
|
|
&self,
|
|
_pe: &PseudoElement,
|
|
_context: &mut MatchingContext<'_, Self::Impl>,
|
|
) -> bool {
|
|
false
|
|
}
|
|
|
|
fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {}
|
|
|
|
fn is_link(&self) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.as_element()
|
|
.map(|name| {
|
|
matches!(name.local.as_ref(), "a" | "area" | "link")
|
|
&& n.get_attribute("href").is_some()
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn is_html_slot_element(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn assigned_slot(&self) -> Option<Self> {
|
|
None
|
|
}
|
|
|
|
fn has_id(&self, id: &CssString, case_sensitivity: CaseSensitivity) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.get_attribute("id")
|
|
.map(|value| case_sensitivity.eq(value.as_bytes(), id.0.as_bytes()))
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_class(&self, name: &CssString, case_sensitivity: CaseSensitivity) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.get_attribute("class")
|
|
.map(|class_attr| {
|
|
class_attr
|
|
.split_whitespace()
|
|
.any(|c| case_sensitivity.eq(c.as_bytes(), name.0.as_bytes()))
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_custom_state(&self, _name: &CssString) -> bool {
|
|
false
|
|
}
|
|
|
|
fn imported_part(&self, _name: &CssString) -> Option<CssString> {
|
|
None
|
|
}
|
|
|
|
fn is_part(&self, _name: &CssString) -> bool {
|
|
false
|
|
}
|
|
|
|
fn is_empty(&self) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |node| {
|
|
let mut child = node.first_child;
|
|
while let Some(child_id) = child {
|
|
if let Some(child_node) = self.tree.get_node(child_id) {
|
|
match &child_node.data {
|
|
NodeData::Element { .. } => return false,
|
|
NodeData::Text { contents } if !contents.is_empty() => return false,
|
|
_ => {}
|
|
}
|
|
child = child_node.next_sibling;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
fn is_root(&self) -> bool {
|
|
self.tree
|
|
.with_node(self.node_id, |n| {
|
|
n.parent
|
|
.map(|parent_id| {
|
|
self.tree
|
|
.with_node(parent_id, |p| p.is_document())
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn ignores_nth_child_selectors(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
// Thread-local LRU cache of parsed selectors. Without this every
|
|
// querySelector / querySelectorAll re-parses the selector string;
|
|
// for batch-heavy DOM access (agent scraping a table, framework
|
|
// repeatedly polling for elements) the parse cost adds up to tens
|
|
// of ms per page. Cap of 256 entries fits a typical page's distinct
|
|
// selectors without unbounded memory growth.
|
|
thread_local! {
|
|
static SELECTOR_CACHE: std::cell::RefCell<
|
|
std::collections::HashMap<String, std::sync::Arc<SelectorList<ObscuraSelector>>>,
|
|
> = std::cell::RefCell::new(std::collections::HashMap::with_capacity(64));
|
|
}
|
|
const SELECTOR_CACHE_CAP: usize = 256;
|
|
|
|
fn parse_selector_uncached(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
|
|
let mut parser_input = cssparser::ParserInput::new(selector);
|
|
let mut parser = cssparser::Parser::new(&mut parser_input);
|
|
SelectorList::parse(&ObscuraSelectorParser, &mut parser, ParseRelative::No)
|
|
.map_err(|e| format!("Failed to parse selector '{}': {:?}", selector, e))
|
|
}
|
|
|
|
pub fn parse_selector(selector: &str) -> Result<SelectorList<ObscuraSelector>, String> {
|
|
// Hot path: cached. Cold path: parse + insert.
|
|
if let Some(cached) = SELECTOR_CACHE.with(|c| c.borrow().get(selector).cloned()) {
|
|
return Ok((*cached).clone());
|
|
}
|
|
let parsed = parse_selector_uncached(selector)?;
|
|
let cached = std::sync::Arc::new(parsed.clone());
|
|
SELECTOR_CACHE.with(|c| {
|
|
let mut cache = c.borrow_mut();
|
|
// Crude eviction: if at cap, dump the whole table. A real LRU
|
|
// would be more memory-friendly but selectors are small and 256
|
|
// is comfortably above a single page's distinct-selector count.
|
|
if cache.len() >= SELECTOR_CACHE_CAP {
|
|
cache.clear();
|
|
}
|
|
cache.insert(selector.to_string(), cached);
|
|
});
|
|
Ok(parsed)
|
|
}
|
|
|
|
/// If `selector` is a bare ASCII id selector like `#main`, return the id (without
|
|
/// the `#`). Conservative: escapes, combinators, commas, whitespace, non-ASCII, or
|
|
/// a non-letter/underscore first character fall through to the full selector engine.
|
|
fn simple_id_selector(selector: &str) -> Option<&str> {
|
|
let id = selector.trim().strip_prefix('#')?;
|
|
let first = id.as_bytes().first().copied()?;
|
|
if !(first.is_ascii_alphabetic() || first == b'_') {
|
|
return None;
|
|
}
|
|
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
|
|
Some(id)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
impl DomTree {
|
|
pub fn query_selector(&self, selector: &str) -> Result<Option<NodeId>, String> {
|
|
self.query_selector_from(self.document(), selector)
|
|
}
|
|
|
|
pub fn query_selector_all(&self, selector: &str) -> Result<Vec<NodeId>, String> {
|
|
self.query_selector_all_from(self.document(), selector)
|
|
}
|
|
|
|
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> {
|
|
// 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.
|
|
if let Some(id) = simple_id_selector(selector) {
|
|
match self.get_element_by_id(id) {
|
|
// querySelector matches strict descendants of root only, so the
|
|
// indexed element must have root among its ancestors.
|
|
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 mut caches = selectors::context::SelectorCaches::default();
|
|
let mut context = MatchingContext::new(
|
|
MatchingMode::Normal,
|
|
None,
|
|
&mut caches,
|
|
QuirksMode::NoQuirks,
|
|
NeedsSelectorFlags::No,
|
|
MatchingForInvalidation::No,
|
|
);
|
|
|
|
for desc_id in self.descendants(root) {
|
|
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
|
if is_element {
|
|
let element = DomElement::new(self, desc_id);
|
|
if selectors::matching::matches_selector_list(
|
|
&selector_list,
|
|
&element,
|
|
&mut context,
|
|
) {
|
|
return Ok(Some(desc_id));
|
|
}
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
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();
|
|
let mut context = MatchingContext::new(
|
|
MatchingMode::Normal,
|
|
None,
|
|
&mut caches,
|
|
QuirksMode::NoQuirks,
|
|
NeedsSelectorFlags::No,
|
|
MatchingForInvalidation::No,
|
|
);
|
|
let mut results = Vec::new();
|
|
|
|
for desc_id in self.descendants(root) {
|
|
let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false);
|
|
if is_element {
|
|
let element = DomElement::new(self, desc_id);
|
|
if selectors::matching::matches_selector_list(
|
|
&selector_list,
|
|
&element,
|
|
&mut context,
|
|
) {
|
|
results.push(desc_id);
|
|
}
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::tree_sink::parse_html;
|
|
|
|
#[test]
|
|
fn test_query_selector_tag() {
|
|
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
|
|
let result = tree.query_selector("h1").unwrap();
|
|
assert!(result.is_some());
|
|
let node = tree.get_node(result.unwrap()).unwrap();
|
|
assert_eq!(node.as_element().unwrap().local.as_ref(), "h1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_class() {
|
|
let tree =
|
|
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
|
|
let result = tree.query_selector(".foo").unwrap();
|
|
assert!(result.is_some());
|
|
let node = tree.get_node(result.unwrap()).unwrap();
|
|
assert_eq!(node.get_attribute("class"), Some("foo bar"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_id() {
|
|
let tree = parse_html(r#"<div id="main">Content</div>"#);
|
|
let result = tree.query_selector("#main").unwrap();
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_all() {
|
|
let tree = parse_html("<ul><li>1</li><li>2</li><li>3</li></ul>");
|
|
let results = tree.query_selector_all("li").unwrap();
|
|
assert_eq!(results.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_descendant() {
|
|
let tree =
|
|
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
|
|
let result = tree.query_selector("#outer span").unwrap();
|
|
assert!(result.is_some());
|
|
let node = tree.get_node(result.unwrap()).unwrap();
|
|
assert_eq!(node.as_element().unwrap().local.as_ref(), "span");
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_attribute() {
|
|
let tree = parse_html(
|
|
r#"<input type="text" name="user"><input type="password" name="pass">"#,
|
|
);
|
|
let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
|
|
assert!(result.is_some());
|
|
let node = tree.get_node(result.unwrap()).unwrap();
|
|
assert_eq!(node.get_attribute("name"), Some("pass"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_no_match() {
|
|
let tree = parse_html("<div>Hello</div>");
|
|
let result = tree.query_selector("span").unwrap();
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_complex() {
|
|
let tree = parse_html(
|
|
r#"<div class="container">
|
|
<ul class="list">
|
|
<li class="item active">First</li>
|
|
<li class="item">Second</li>
|
|
<li class="item active">Third</li>
|
|
</ul>
|
|
</div>"#,
|
|
);
|
|
let results = tree.query_selector_all(".list .item.active").unwrap();
|
|
assert_eq!(results.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_all_from_scopes_to_subtree() {
|
|
let tree = parse_html(
|
|
r#"<div id="a"><span class="x">in a</span></div><div id="b"><span class="x">in b</span></div>"#,
|
|
);
|
|
let a = tree.get_element_by_id("a").expect("div#a");
|
|
let b = tree.get_element_by_id("b").expect("div#b");
|
|
|
|
// Document-rooted: sees both spans.
|
|
assert_eq!(tree.query_selector_all(".x").unwrap().len(), 2);
|
|
// Scoped to #a: only its descendant span.
|
|
let in_a = tree.query_selector_all_from(a, ".x").unwrap();
|
|
assert_eq!(in_a.len(), 1);
|
|
let in_b = tree.query_selector_all_from(b, ".x").unwrap();
|
|
assert_eq!(in_b.len(), 1);
|
|
assert_ne!(in_a[0], in_b[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_from_returns_first_in_subtree_only() {
|
|
let tree = parse_html(
|
|
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#,
|
|
);
|
|
let s = tree.get_element_by_id("s").expect("section#s");
|
|
|
|
// Scoped to #s: skip the outside paragraph; return the first inside.
|
|
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside");
|
|
assert_eq!(tree.text_content(first_in_s), "first");
|
|
}
|
|
|
|
#[test]
|
|
fn test_query_selector_from_excludes_self() {
|
|
// The root element itself must not match its own scoped query, per
|
|
// the spec: querySelector matches descendants only.
|
|
let tree = parse_html(r#"<div id="root" class="x"><span>child</span></div>"#);
|
|
let root = tree.get_element_by_id("root").expect("div#root");
|
|
|
|
// Only descendants are candidates: `.x` on root finds nothing.
|
|
assert!(tree.query_selector_from(root, ".x").unwrap().is_none());
|
|
// `span` finds the child.
|
|
assert!(tree.query_selector_from(root, "span").unwrap().is_some());
|
|
}
|
|
}
|