diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index a9270cf88662..69b09fd13672 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1918,6 +1918,7 @@ pub mod formatter { NativeCode, JSX, + DOMNode, Event, GetterSetter, @@ -1954,6 +1955,7 @@ pub mod formatter { | Tag::Error | Tag::Class | Tag::Event + | Tag::DOMNode ) } } @@ -1988,6 +1990,7 @@ pub mod formatter { ToJSON, NativeCode, JSX, + DOMNode, Event, GetterSetter, CustomGetterSetter, @@ -2034,6 +2037,7 @@ pub mod formatter { TagPayload::ToJSON => Tag::ToJSON, TagPayload::NativeCode => Tag::NativeCode, TagPayload::JSX => Tag::JSX, + TagPayload::DOMNode => Tag::DOMNode, TagPayload::Event => Tag::Event, TagPayload::GetterSetter => Tag::GetterSetter, TagPayload::CustomGetterSetter => Tag::CustomGetterSetter, @@ -2079,6 +2083,7 @@ pub mod formatter { Tag::ToJSON => TagPayload::ToJSON, Tag::NativeCode => TagPayload::NativeCode, Tag::JSX => TagPayload::JSX, + Tag::DOMNode => TagPayload::DOMNode, Tag::Event => TagPayload::Event, Tag::GetterSetter => TagPayload::GetterSetter, Tag::CustomGetterSetter => TagPayload::CustomGetterSetter, @@ -2274,6 +2279,16 @@ pub mod formatter { } } + // Is this a DOM node (jsdom / happy-dom)? + if matches!(js_type, jsc::JSType::Object | jsc::JSType::FinalObject) + && is_dom_node(global_this, value)? + { + return Ok(TagResult { + tag: TagPayload::DOMNode, + cell: js_type, + }); + } + use jsc::JSType as T; let tag = match js_type { T::ErrorInstance => TagPayload::Error, @@ -3245,6 +3260,86 @@ pub mod formatter { Ok(None) } + pub const DOM_ELEMENT_NODE: i32 = 1; + pub const DOM_TEXT_NODE: i32 = 3; + pub const DOM_COMMENT_NODE: i32 = 8; + pub const DOM_FRAGMENT_NODE: i32 = 11; + + /// Constructor name → expected `nodeType` (pretty-format `DOMElement` plugin rule). + pub fn dom_node_type_for_class_name(name: &[u8]) -> Option { + use bun_core::strings; + // /^((HTML|SVG)\w*)?Element$/ + if strings::has_suffix_comptime(name, b"Element") + && (name.len() == b"Element".len() + || strings::has_prefix_comptime(name, b"HTML") + || strings::has_prefix_comptime(name, b"SVG")) + { + return Some(DOM_ELEMENT_NODE); + } + match name { + b"Text" => Some(DOM_TEXT_NODE), + b"Comment" => Some(DOM_COMMENT_NODE), + b"DocumentFragment" => Some(DOM_FRAGMENT_NODE), + _ => None, + } + } + + /// True when `value` duck-types as a jsdom/happy-dom node. `#[inline(never)]` keeps `Tag::get` frames small. + #[inline(never)] + pub fn is_dom_node(global_this: &JSGlobalObject, value: JSValue) -> JsResult { + // DOM nodes inherit through `Node`→`EventTarget`; a null grand-proto means plain `{}`. + let proto = value.get_prototype(global_this); + if proto.is_empty_or_undefined_or_null() + || !proto.is_cell() + || proto.js_type() == jsc::JSType::ProxyObject + { + return Ok(false); + } + let grand = proto.get_prototype(global_this); + if grand.is_empty_or_undefined_or_null() { + return Ok(false); + } + + let mut name_str = ZigString::init(b""); + value.get_class_name(global_this, &mut name_str)?; + let name_slice = name_str.to_slice(); + let expected = match dom_node_type_for_class_name(name_slice.slice()) { + Some(e) => e, + // Custom elements: walk up until an `*Element` ancestor is found. + None => 'walk: { + let mut cur = grand; + for _ in 0..8 { + if !cur.is_cell() || cur.js_type() == jsc::JSType::ProxyObject { + return Ok(false); + } + let next = cur.get_prototype(global_this); + if next.is_empty_or_undefined_or_null() { + return Ok(false); + } + let mut proto_name = ZigString::init(b""); + cur.get_class_name(global_this, &mut proto_name)?; + let proto_slice = proto_name.to_slice(); + if let Some(DOM_ELEMENT_NODE) = + dom_node_type_for_class_name(proto_slice.slice()) + { + break 'walk DOM_ELEMENT_NODE; + } + cur = next; + } + return Ok(false); + } + }; + let confirmed = match value.get(global_this, "nodeType") { + Ok(Some(n)) if n.is_int32() => n.to_int32() == expected, + Ok(_) => false, + Err(_) => { + global_this.clear_exception_except_termination(); + false + } + }; + Ok(confirmed) + } + // `JSGlobalObject` is an opaque `UnsafeCell`-backed ZST handle; remaining // params are by-value `JSValue`/scalars → `safe fn`. unsafe extern "C" { @@ -3411,6 +3506,7 @@ pub mod formatter { &mut remove_before_recurse, ), Tag::JSX => self.print_jsx::(writer_, value), + Tag::DOMNode => self.print_dom_node::(writer_, value), Tag::Object => self.print_object::(writer_, value, js_type), Tag::TypedArray => { self.print_typed_array::(writer_, value, js_type) @@ -5390,6 +5486,211 @@ pub mod formatter { Ok(()) } + /// Prints a jsdom/happy-dom node as markup (``). + #[inline(never)] + fn print_dom_node( + &mut self, + writer_: &mut dyn bun_io::Write, + value: JSValue, + ) -> JsResult<()> { + macro_rules! pf { + ($s:literal) => { + pfmt!($s, C) + }; + } + macro_rules! get_swallow { + ($v:expr, $name:literal) => { + match $v.get(self.global_this, $name) { + Ok(v) => v, + Err(_) => { + self.global_this.clear_exception_except_termination(); + None + } + } + }; + } + + let node_type = get_swallow!(value, "nodeType") + .filter(|v| v.is_int32()) + .map(|v| v.to_int32()) + .unwrap_or(0); + + if matches!(node_type, DOM_TEXT_NODE | DOM_COMMENT_NODE) { + let data = get_swallow!(value, "data"); + let text = match data { + Some(v) if v.is_string() => { + bun_core::OwnedString::new(v.to_bun_string(self.global_this)?) + } + _ => bun_core::OwnedString::new(bun_core::String::empty()), + }; + if node_type == DOM_COMMENT_NODE { + let _ = write!(writer_, "{}{}", pf!(""), text, pf!("")); + } else { + let _ = write!(writer_, "{}", text); + } + return Ok(()); + } + + let (tag_utf8, is_fragment) = if node_type == DOM_FRAGMENT_NODE { + ( + bun_core::ZigStringSlice::from_utf8_never_free(b"DocumentFragment"), + true, + ) + } else { + let tag_name = get_swallow!(value, "tagName"); + match tag_name { + Some(v) if v.is_string() => { + let s = v.get_zig_string(self.global_this)?; + let slice = s.to_slice(); + let mut owned = slice.slice().to_vec(); + owned.make_ascii_lowercase(); + (bun_core::ZigStringSlice::init_owned(owned), false) + } + _ => ( + bun_core::ZigStringSlice::from_utf8_never_free(b"unknown"), + false, + ), + } + }; + let tag_bytes = tag_utf8.slice(); + + if self.depth >= self.max_depth { + let _ = write!( + writer_, + "{}<{}{} \u{2026} {}/>{}", + pf!(""), + bstr::BStr::new(tag_bytes), + pf!(""), + pf!(""), + pf!(""), + ); + return Ok(()); + } + + let _ = writer_.write_all(pf!("").as_bytes()); + let _ = writer_.write_all(b"<"); + let _ = writer_.write_all(tag_bytes); + let _ = writer_.write_all(pf!("").as_bytes()); + + let mut attrs_multiline = false; + if !is_fragment { + if let Some(attrs) = get_swallow!(value, "attributes") { + if attrs.is_cell() && attrs.is_object() { + if let Some(len_v) = get_swallow!(attrs, "length") { + if len_v.is_int32() { + let n = len_v.to_int32().max(0) as u32; + let mut pairs: Vec<(Vec, Vec)> = + Vec::with_capacity(n.min(64) as usize); + for i in 0..n { + let Ok(attr) = attrs.get_index(self.global_this, i) else { + self.global_this.clear_exception_except_termination(); + continue; + }; + if !attr.is_cell() || !attr.is_object() { + continue; + } + let Some(name) = get_swallow!(attr, "name") else { + continue; + }; + if !name.is_string() { + continue; + } + let name_s = name.get_zig_string(self.global_this)?; + let name_owned = name_s.to_slice().slice().to_vec(); + let val = match get_swallow!(attr, "value") { + Some(v) if v.is_string() => v + .get_zig_string(self.global_this)? + .to_slice() + .slice() + .to_vec(), + _ => Vec::new(), + }; + pairs.push((name_owned, val)); + } + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + attrs_multiline = !self.single_line && pairs.len() > 1; + for (name, val) in &pairs { + if attrs_multiline { + let _ = writer_.write_all(b"\n"); + let _ = write_indent_n(self.indent + 1, writer_); + } else { + let _ = writer_.write_all(b" "); + } + let _ = write!( + writer_, + "{}{}{}={}{}{}", + pf!(""), + bstr::BStr::new(name), + pf!(""), + pf!(""), + bun_core::fmt::quote(val), + pf!(""), + ); + } + if attrs_multiline { + let _ = writer_.write_all(b"\n"); + let _ = write_indent_n(self.indent, writer_); + } + } + } + } + } + } + + let children = + get_swallow!(value, "childNodes").filter(|v| v.is_cell() && v.is_object()); + let child_len = children + .and_then(|c| get_swallow!(c, "length")) + .filter(|v| v.is_int32()) + .map(|v| v.to_int32().max(0) as u32) + .unwrap_or(0); + + if child_len == 0 { + if attrs_multiline { + let _ = write!(writer_, "{}/>{}", pf!(""), pf!("")); + } else { + let _ = write!(writer_, "{} />{}", pf!(""), pf!("")); + } + return Ok(()); + } + + let _ = write!(writer_, "{}>{}", pf!(""), pf!("")); + { + self.indent += 1; + self.depth += 1; + let _ind = defer_decrement!(self.indent); + let _dep = defer_decrement!(self.depth); + let children = children.expect("child_len > 0 implies Some"); + for i in 0..child_len { + if !self.single_line { + let _ = writer_.write_all(b"\n"); + let _ = write_indent_n(self.indent, writer_); + } + let Ok(child) = children.get_index(self.global_this, i) else { + self.global_this.clear_exception_except_termination(); + continue; + }; + if !child.is_cell() { + continue; + } + let tag = Tag::get_advanced(child, self.global_this, self.tag_opts())?; + self.format::(tag, writer_, child, self.global_this)?; + } + } + if !self.single_line { + let _ = writer_.write_all(b"\n"); + let _ = write_indent_n(self.indent, writer_); + } + let _ = write!( + writer_, + "{}{}", + pf!(""), + bstr::BStr::new(tag_bytes), + pf!(""), + ); + Ok(()) + } + #[inline(never)] fn print_object( &mut self, diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index ed3cf5371ac7..16c3663eaa0f 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -398,6 +398,7 @@ pub enum Tag { ArrayBuffer, JSX, + DOMNode, Event, } @@ -419,7 +420,7 @@ impl Tag { #[inline] pub(crate) const fn can_have_circular_references(self) -> bool { - matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set) + matches!(self, Tag::Array | Tag::Object | Tag::Map | Tag::Set | Tag::DOMNode) } } @@ -435,6 +436,19 @@ impl Default for TagResult { } } +#[inline(never)] +fn is_dom_node_tag( + global_this: &JSGlobalObject, + value: JSValue, + js_type: JSType, +) -> JsResult> { + if bun_jsc::console_object::formatter::is_dom_node(global_this, value)? { + Ok(Some(TagResult { tag: Tag::DOMNode, cell: js_type })) + } else { + Ok(None) + } +} + impl Tag { pub fn get(value: JSValue, global_this: &JSGlobalObject) -> JsResult { if value.is_empty() || value == JSValue::UNDEFINED { @@ -510,6 +524,13 @@ impl Tag { } } + // Is this a DOM node (jsdom / happy-dom)? + if matches!(js_type, JSType::Object | JSType::FinalObject) { + if let Some(r) = is_dom_node_tag(global_this, value, js_type)? { + return Ok(r); + } + } + let tag = match js_type { JSType::ErrorInstance => Tag::Error, JSType::NumberObject => Tag::Double, @@ -1038,6 +1059,185 @@ impl<'a, 'f, W: bun_io::Write, const ENABLE_ANSI_COLORS: bool> } impl<'a> Formatter<'a> { + /// Prints a jsdom/happy-dom node as markup (mirrors pretty-format `DOMElement`). + #[inline(never)] + fn print_dom_node( + &mut self, + writer_: &mut W, + value: JSValue, + ) -> JsResult<()> { + macro_rules! pf { + ($s:literal) => { + pretty_fmt_const::($s) + }; + } + macro_rules! get_swallow { + ($v:expr, $name:literal) => { + match $v.get(self.global_this, $name) { + Ok(v) => v, + Err(_) => { + self.global_this.clear_exception_except_termination(); + None + } + } + }; + } + + use bun_jsc::console_object::formatter::{DOM_COMMENT_NODE, DOM_FRAGMENT_NODE, DOM_TEXT_NODE}; + + let node_type = get_swallow!(value, "nodeType") + .filter(|v| v.is_int32()) + .map(|v| v.to_int32()) + .unwrap_or(0); + + if matches!(node_type, DOM_TEXT_NODE | DOM_COMMENT_NODE) { + let data = get_swallow!(value, "data"); + let text = match data { + Some(v) if v.is_string() => { + bun_core::OwnedString::new(v.to_bun_string(self.global_this)?) + } + _ => bun_core::OwnedString::new(bun_core::String::empty()), + }; + if node_type == DOM_COMMENT_NODE { + let _ = write!(writer_, "{}{}", pf!(""), text, pf!("")); + } else { + let _ = write!(writer_, "{}", text); + } + return Ok(()); + } + + let (tag_utf8, is_fragment) = if node_type == DOM_FRAGMENT_NODE { + (bun_core::ZigStringSlice::from_utf8_never_free(b"DocumentFragment"), true) + } else { + let tag_name = get_swallow!(value, "tagName"); + match tag_name { + Some(v) if v.is_string() => { + let s = v.get_zig_string(self.global_this)?; + let slice = s.to_slice(); + let mut owned = slice.slice().to_vec(); + owned.make_ascii_lowercase(); + (bun_core::ZigStringSlice::init_owned(owned), false) + } + _ => (bun_core::ZigStringSlice::from_utf8_never_free(b"unknown"), false), + } + }; + let tag_bytes = tag_utf8.slice(); + + let _ = writer_.write_all(pf!("").as_bytes()); + let _ = writer_.write_all(b"<"); + let _ = writer_.write_all(tag_bytes); + let _ = writer_.write_all(pf!("").as_bytes()); + + let mut has_attrs = false; + if !is_fragment { + if let Some(attrs) = get_swallow!(value, "attributes") { + if attrs.is_cell() && attrs.is_object() { + if let Some(len_v) = get_swallow!(attrs, "length") { + if len_v.is_int32() { + let n = len_v.to_int32().max(0) as u32; + let mut pairs: Vec<(Vec, Vec)> = + Vec::with_capacity(n.min(64) as usize); + for i in 0..n { + let Ok(attr) = attrs.get_index(self.global_this, i) else { + self.global_this.clear_exception_except_termination(); + continue; + }; + if !attr.is_cell() || !attr.is_object() { + continue; + } + let Some(name) = get_swallow!(attr, "name") else { continue }; + if !name.is_string() { + continue; + } + let name_s = name.get_zig_string(self.global_this)?; + let name_owned = name_s.to_slice().slice().to_vec(); + let val = match get_swallow!(attr, "value") { + Some(v) if v.is_string() => { + v.get_zig_string(self.global_this)?.to_slice().slice().to_vec() + } + _ => Vec::new(), + }; + pairs.push((name_owned, val)); + } + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + self.indent += 1; + for (name, val) in &pairs { + has_attrs = true; + let _ = writer_.write_all(b"\n"); + let _ = self.write_indent(writer_); + let _ = write!( + writer_, + "{}{}{}={}{}{}", + pf!(""), + bstr::BStr::new(name), + pf!(""), + pf!(""), + bun_core::fmt::quote(val), + pf!(""), + ); + } + self.indent = self.indent.saturating_sub(1); + if has_attrs { + let _ = writer_.write_all(b"\n"); + let _ = self.write_indent(writer_); + } + } + } + } + } + } + + let children = get_swallow!(value, "childNodes").filter(|v| v.is_cell() && v.is_object()); + let child_len = children + .and_then(|c| get_swallow!(c, "length")) + .filter(|v| v.is_int32()) + .map(|v| v.to_int32().max(0) as u32) + .unwrap_or(0); + + if child_len == 0 { + if has_attrs { + let _ = write!(writer_, "{}/>{}", pf!(""), pf!("")); + } else { + let _ = write!(writer_, "{} />{}", pf!(""), pf!("")); + } + return Ok(()); + } + + let _ = write!(writer_, "{}>{}", pf!(""), pf!("")); + { + self.indent += 1; + let children = children.expect("child_len > 0 implies Some"); + let inner: JsResult<()> = (|| { + for i in 0..child_len { + let _ = writer_.write_all(b"\n"); + let _ = self.write_indent(writer_); + let Ok(child) = children.get_index(self.global_this, i) else { + self.global_this.clear_exception_except_termination(); + continue; + }; + if !child.is_cell() { + continue; + } + let tag = Tag::get(child, self.global_this)?; + self.format::(tag, writer_, child, self.global_this)?; + } + Ok(()) + })(); + self.indent = self.indent.saturating_sub(1); + inner?; + } + let _ = writer_.write_all(b"\n"); + let _ = self.write_indent(writer_); + let _ = write!( + writer_, + "{}{}", + pf!(""), + bstr::BStr::new(tag_bytes), + pf!(""), + ); + Ok(()) + } + pub(crate) fn print_as( &mut self, writer_: &mut W, @@ -2299,6 +2499,9 @@ impl<'a> Formatter<'a> { writer.write_all(b" />"); } + Tag::DOMNode => { + self.print_dom_node::(writer.ctx, value)?; + } Tag::Object => { let prev_quote_strings = self.quote_strings; self.quote_strings = true; @@ -2582,6 +2785,9 @@ impl<'a> Formatter<'a> { Tag::JSX => { self.print_as::(writer, value, result.cell) } + Tag::DOMNode => { + self.print_as::(writer, value, result.cell) + } Tag::Event => { self.print_as::(writer, value, result.cell) } @@ -2655,6 +2861,7 @@ impl bun_jsc::ConsoleFormatter for Formatter<'_> { Ft::JSON => Tag::JSON, Ft::NativeCode => Tag::NativeCode, Ft::JSX => Tag::JSX, + Ft::DOMNode => Tag::DOMNode, Ft::Event => Tag::Event, // Variants the test-runner formatter has no dedicated arm for: Ft::MapIterator diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5c91f3dbe05f..17cf761318e7 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -928,3 +928,185 @@ describe.skipIf(!isASAN)("object mutated while being formatted", () => { expect(exitCode).toBe(0); }); }); + +// https://github.com/oven-sh/bun/issues/10886 +describe("DOM nodes", () => { + function attr(name, value) { + return new (class Attr { + get name() { + return name; + } + get value() { + return value; + } + })(); + } + function element(Ctor, tagName, attrs, children) { + return Object.assign(new Ctor(), { + get nodeType() { + return 1; + }, + get tagName() { + return tagName; + }, + get attributes() { + return attrs; + }, + get childNodes() { + return children; + }, + }); + } + class HTMLButtonElement {} + class HTMLDivElement {} + class SVGSVGElement {} + class Text { + nodeType = 3; + constructor(data) { + this.data = data; + } + } + class Comment { + nodeType = 8; + constructor(data) { + this.data = data; + } + } + class DocumentFragment { + nodeType = 11; + constructor(children) { + this.childNodes = children; + } + } + + it("HTMLButtonElement, no attributes, no children", () => { + const button = element(HTMLButtonElement, "BUTTON", [], []); + expect(Bun.inspect(button)).toBe("