From 86161160532266444449bfd7e754a847fc8d613f Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 25 Jul 2026 13:11:42 +0000
Subject: [PATCH 01/10] console/test: serialize DOM nodes as markup in
Bun.inspect and matcher utils
Adds a DOMNode tag to ConsoleObject::Formatter and the test-runner
pretty-format so objects from jsdom / happy-dom whose constructor name
matches the pretty-format DOMElement plugin pattern (HTML*/SVG*/Element,
Text, Comment, DocumentFragment) and whose nodeType agrees are printed
as markup () instead of having their entire
object graph dumped.
This covers:
- console.log / Bun.inspect
- this.utils.printReceived / printExpected / stringify in custom matchers
- toMatchSnapshot / toMatchInlineSnapshot
Detection bails out in two pointer reads for plain {} / Object.create(null)
so the common path does not pay for a calculatedClassName walk.
Fixes #10886
---
src/jsc/ConsoleObject.rs | 286 +++++++++++++++++++++++
src/runtime/test_runner/pretty_format.rs | 210 ++++++++++++++++-
test/js/bun/util/inspect.test.js | 139 +++++++++++
3 files changed, 634 insertions(+), 1 deletion(-)
diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs
index e9f3b7261673..c826f654cce3 100644
--- a/src/jsc/ConsoleObject.rs
+++ b/src/jsc/ConsoleObject.rs
@@ -1974,6 +1974,7 @@ pub mod formatter {
NativeCode,
JSX,
+ DOMNode,
Event,
GetterSetter,
@@ -2010,6 +2011,7 @@ pub mod formatter {
| Tag::Error
| Tag::Class
| Tag::Event
+ | Tag::DOMNode
)
}
}
@@ -2044,6 +2046,7 @@ pub mod formatter {
ToJSON,
NativeCode,
JSX,
+ DOMNode,
Event,
GetterSetter,
CustomGetterSetter,
@@ -2090,6 +2093,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,
@@ -2135,6 +2139,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,
@@ -2330,6 +2335,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,
@@ -3304,6 +3319,67 @@ pub mod formatter {
Ok(None)
}
+ const DOM_ELEMENT_NODE: i32 = 1;
+ const DOM_TEXT_NODE: i32 = 3;
+ const DOM_COMMENT_NODE: i32 = 8;
+ const DOM_FRAGMENT_NODE: i32 = 11;
+
+ /// Mirrors the pretty-format `DOMElement` plugin test: given a constructor
+ /// name, return the `nodeType` value that would confirm the object is a DOM
+ /// node of that kind, or `None` if the name does not look DOM-like.
+ 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,
+ }
+ }
+
+ /// DOM node detection (jsdom / happy-dom). Restricted to values whose
+ /// class name matches a known DOM pattern so the `nodeType` prototype
+ /// getter is never invoked on unrelated objects.
+ #[inline(never)]
+ pub fn is_dom_node(global_this: &JSGlobalObject, value: JSValue) -> JsResult {
+ // Fast bail for plain `{}` / `Object.create(null)` / one-level classes:
+ // DOM nodes inherit through at least `Node` → `EventTarget`, so an
+ // object whose prototype is `Object.prototype` (grand-proto is null)
+ // cannot be one and skips the `calculatedClassName` walk below.
+ let proto = value.get_prototype(global_this);
+ if proto.is_empty_or_undefined_or_null() {
+ 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 Some(expected) = dom_node_type_for_class_name(name_slice.slice()) else {
+ 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" {
@@ -3470,6 +3546,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)
@@ -5449,6 +5526,215 @@ pub mod formatter {
Ok(())
}
+ /// Serializes a DOM node (jsdom / happy-dom) as markup, mirroring the
+ /// `pretty-format` `DOMElement` plugin so jest-dom matcher messages and
+ /// snapshots render `` instead of the full
+ /// object graph.
+ #[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, bun_core::OwnedString)> =
+ 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 = get_swallow!(attr, "value")
+ .filter(|v| v.is_string())
+ .map(|v| v.to_bun_string(self.global_this))
+ .transpose()?
+ .map(bun_core::OwnedString::new)
+ .unwrap_or_else(|| {
+ bun_core::OwnedString::new(bun_core::String::empty())
+ });
+ pairs.push((name_owned, val));
+ }
+ pairs.sort_by(|a, b| a.0.cmp(&b.0));
+ attrs_multiline =
+ !self.single_line && !pairs.is_empty() && 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!(""),
+ 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 b4a68cca5c07..884c432edffe 100644
--- a/src/runtime/test_runner/pretty_format.rs
+++ b/src/runtime/test_runner/pretty_format.rs
@@ -450,6 +450,7 @@ pub enum Tag {
ArrayBuffer,
JSX,
+ DOMNode,
Event,
}
@@ -471,7 +472,7 @@ impl Tag {
#[inline]
pub 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)
}
}
@@ -487,6 +488,19 @@ impl Default for TagResult {
}
}
+#[inline(never)]
+fn is_dom_node_tag(
+ global_this: &JSGlobalObject,
+ value: JSValue,
+ js_type: JSType,
+) -> JsResult