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> { + 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 { @@ -562,6 +576,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, @@ -1090,6 +1111,186 @@ impl<'a, 'f, W: bun_io::Write, const ENABLE_ANSI_COLORS: bool> } impl<'a> Formatter<'a> { + /// Serializes a DOM node (jsdom / happy-dom) as markup, mirroring the + /// `pretty-format` `DOMElement` plugin used by Jest's snapshot serializer. + #[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 + } + } + }; + } + + let node_type = get_swallow!(value, "nodeType") + .filter(|v| v.is_int32()) + .map(|v| v.to_int32()) + .unwrap_or(0); + + if node_type == 3 || node_type == 8 { + 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 == 8 { + let _ = write!(writer_, "{}{}", pf!(""), text, pf!("")); + } else { + let _ = write!(writer_, "{}", text); + } + return Ok(()); + } + + let (tag_utf8, is_fragment) = if node_type == 11 { + (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, 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)); + 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!(""), + 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 fn print_as( &mut self, writer_: &mut W, @@ -2344,6 +2545,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; @@ -2627,6 +2831,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) } @@ -2700,6 +2907,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 e76766e7f42f..32e6809976e6 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -803,3 +803,142 @@ it("CustomEvent", () => { }" `); }); + +// https://github.com/oven-sh/bun/issues/10886 +// DOM nodes (jsdom / happy-dom) are duck-typed: an object whose constructor +// name matches a DOM class *and* whose nodeType agrees is serialized as markup +// instead of having its internals dumped. +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("` instead of the full - /// object graph. + /// Prints a jsdom/happy-dom node as markup (``). #[inline(never)] fn print_dom_node( &mut self, @@ -5622,7 +5615,7 @@ pub mod formatter { 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)> = + 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 { @@ -5640,19 +5633,19 @@ pub mod formatter { } 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()) - }); + 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.is_empty() && pairs.len() > 1; + attrs_multiline = !self.single_line && pairs.len() > 1; for (name, val) in &pairs { if attrs_multiline { let _ = writer_.write_all(b"\n"); @@ -5662,12 +5655,12 @@ pub mod formatter { } let _ = write!( writer_, - "{}{}{}={}\"{}\"{}", + "{}{}{}={}{}{}", pf!(""), bstr::BStr::new(name), pf!(""), pf!(""), - val, + bun_core::fmt::quote(val), pf!(""), ); } diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 884c432edffe..e882891be134 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -1111,8 +1111,7 @@ impl<'a, 'f, W: bun_io::Write, const ENABLE_ANSI_COLORS: bool> } impl<'a> Formatter<'a> { - /// Serializes a DOM node (jsdom / happy-dom) as markup, mirroring the - /// `pretty-format` `DOMElement` plugin used by Jest's snapshot serializer. + /// Prints a jsdom/happy-dom node as markup (mirrors pretty-format `DOMElement`). #[inline(never)] fn print_dom_node( &mut self, @@ -1136,12 +1135,14 @@ impl<'a> Formatter<'a> { }; } + 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 node_type == 3 || node_type == 8 { + 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() => { @@ -1149,7 +1150,7 @@ impl<'a> Formatter<'a> { } _ => bun_core::OwnedString::new(bun_core::String::empty()), }; - if node_type == 8 { + if node_type == DOM_COMMENT_NODE { let _ = write!(writer_, "{}{}", pf!(""), text, pf!("")); } else { let _ = write!(writer_, "{}", text); @@ -1157,7 +1158,7 @@ impl<'a> Formatter<'a> { return Ok(()); } - let (tag_utf8, is_fragment) = if node_type == 11 { + 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"); @@ -1186,7 +1187,7 @@ impl<'a> Formatter<'a> { 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)> = + 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 { @@ -1202,14 +1203,12 @@ impl<'a> Formatter<'a> { } 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()) - }); + 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)); @@ -1220,12 +1219,12 @@ impl<'a> Formatter<'a> { let _ = self.write_indent(writer_); let _ = write!( writer_, - "{}{}{}={}\"{}\"{}", + "{}{}{}={}{}{}", pf!(""), bstr::BStr::new(name), pf!(""), pf!(""), - val, + bun_core::fmt::quote(val), pf!(""), ); } diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 64acc02a61db..2d64afcc85d4 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -805,9 +805,6 @@ it("CustomEvent", () => { }); // https://github.com/oven-sh/bun/issues/10886 -// DOM nodes (jsdom / happy-dom) are duck-typed: an object whose constructor -// name matches a DOM class *and* whose nodeType agrees is serialized as markup -// instead of having its internals dumped. describe("DOM nodes", () => { function attr(name, value) { return new (class Attr { @@ -884,6 +881,11 @@ describe("DOM nodes", () => { expect(Bun.inspect(svg)).toBe(''); }); + it("attribute value with quotes is escaped", () => { + const div = element(HTMLDivElement, "DIV", [attr("title", 'Say "Hi"')], []); + expect(Bun.inspect(div)).toBe('
'); + }); + it("Text node", () => { expect(Bun.inspect(new Text("some text"))).toBe("some text"); }); @@ -919,6 +921,35 @@ describe("DOM nodes", () => { expect(stringified).toBe('
`); - expect(new DocumentFragment([element(HTMLButtonElement, "BUTTON", [], [])])).toMatchInlineSnapshot(` + expect(new DocumentFragment([element(HTMLButtonElement, "BUTTON", [], [])])) + .toMatchInlineSnapshot(` \n\n'); + expect(exitCode).toBe(0); + }); }); From e1b80793e3819279bdb226756bb2417bfdd97be2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:35:03 +0000 Subject: [PATCH 09/10] drop happy-dom subprocess test (20s in debug); duck-typed fixtures cover same path --- test/js/bun/util/inspect.test.js | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 5ffa8f2a038a..b21712d1a5d7 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -984,32 +984,4 @@ describe("DOM nodes", () => { } expect(Bun.inspect(new HTMLThrowElement())).toStartWith("HTMLThrowElement {"); }); - - it("happy-dom elements (real prototype-accessor shapes)", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const { Window } = require("happy-dom"); - const { document, customElements, HTMLElement } = new Window(); - const btn = document.createElement("button"); - btn.setAttribute("type", "submit"); - btn.textContent = "go"; - console.log(Bun.inspect(btn)); - customElements.define("my-widget", class MyWidget extends HTMLElement {}); - const w = document.createElement("my-widget"); - console.log(Bun.inspect(w)); - `, - ], - env: bunEnv, - cwd: import.meta.dir, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe('\n\n'); - expect(exitCode).toBe(0); - }); }); From 97f8ce952c56ab55258309e56217d8553e74f59a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:02:02 +0000 Subject: [PATCH 10/10] ci: retrigger