Files
obscura/crates/obscura-dom/src/selector.rs
T
Andrey Mnatsakanov 72a9c3ced3 fix(dom): honor quirks mode for class/id selector case-sensitivity
set_quirks_mode was a no-op and the selector MatchingContext hardcoded
QuirksMode::NoQuirks, so class/id selectors were always case-sensitive even on
quirks-mode (no-doctype) pages where browsers match .foo / #bar
ASCII-case-insensitively. Store the parser-reported quirks mode on DomTree, pass
it into the selector context, and skip the exact-case #id index fast path in
quirks mode so it falls through to case-insensitive matching.

Fixes #499
2026-07-26 13:29:12 +02:00

833 lines
28 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>;
// Allow `:has()`. The selectors crate gates relative-selector parsing on
// this (default false), so without it `a:has(p)` failed to parse and the
// error was swallowed into an empty match set. Matching already passes a
// SelectorCaches (which holds the relative-selector cache), so enabling
// parsing is sufficient.
fn parse_has(&self) -> bool {
true
}
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 {
// Must be stable per node. DomElement is Copy and gets a fresh stack
// address on every traversal step, so OpaqueElement::new(self) returns a
// different id for the same node each call. That breaks `:has` anchor
// matching, which compares the anchor's opaque against the element
// reached by walking up the tree. Key off the node's stable slot in the
// tree's Vec instead (OpaqueElement only compares the address, never
// dereferences it, and the Vec is not mutated during a query).
let inner = self.tree.borrow_inner();
match inner.nodes.get(self.node_id.index()) {
Some(slot) => OpaqueElement::new(slot),
None => 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.
// 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
// 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,
self.selector_quirks_mode(),
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)
}
// 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();
let mut context = MatchingContext::new(
MatchingMode::Normal,
None,
&mut caches,
self.selector_quirks_mode(),
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_has_parses() {
// Isolate parse from match: :has must parse, not error.
assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse");
}
#[test]
fn test_query_selector_has() {
let tree = parse_html(r#"<a><p class="bt">x</p></a>"#);
let all = tree.query_selector_all("a:has(p.bt)").unwrap();
assert_eq!(all.len(), 1, "a:has(p.bt) should match the <a>");
let none = tree.query_selector_all("a:has(span.bt)").unwrap();
assert_eq!(none.len(), 0, "a:has(span.bt) should match nothing");
}
#[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 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(
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());
}
}