diff --git a/Cargo.lock b/Cargo.lock index bde3e61e05d..fa16f19e49f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -575,6 +575,7 @@ dependencies = [ "boa_engine", "boa_gc", "bytemuck", + "comfy-table", "either", "futures", "futures-lite", diff --git a/core/runtime/Cargo.toml b/core/runtime/Cargo.toml index dd75e661be8..5e651d8a6c8 100644 --- a/core/runtime/Cargo.toml +++ b/core/runtime/Cargo.toml @@ -20,6 +20,7 @@ futures = "0.3.32" futures-lite.workspace = true http = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +comfy-table.workspace = true rustc-hash = { workspace = true, features = ["std"] } serde_json = { workspace = true, optional = true } url = { workspace = true, optional = true } diff --git a/core/runtime/src/console/mod.rs b/core/runtime/src/console/mod.rs index 2942be12380..8bb4b3b892b 100644 --- a/core/runtime/src/console/mod.rs +++ b/core/runtime/src/console/mod.rs @@ -11,18 +11,23 @@ //! [spec]: https://console.spec.whatwg.org/ //! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Console +mod table; #[cfg(test)] pub(crate) mod tests; +pub use table::TableData; + use boa_engine::JsVariant; use boa_engine::property::Attribute; use boa_engine::{ - Context, JsArgs, JsData, JsError, JsResult, JsString, JsSymbol, js_str, js_string, + Context, JsArgs, JsData, JsError, JsNativeError, JsResult, JsString, JsSymbol, js_str, + js_string, native_function::NativeFunction, object::{JsObject, ObjectInitializer}, value::{JsValue, Numeric}, }; use boa_gc::{Finalize, Trace}; +use comfy_table::{Cell, Table}; use rustc_hash::FxHashMap; use std::{ cell::RefCell, collections::hash_map::Entry, fmt::Write as _, io::Write, rc::Rc, @@ -83,6 +88,29 @@ pub trait Logger: Trace { /// # Errors /// Returning an error will throw an exception in JavaScript. fn error(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()>; + + /// Log tabular data (`console.table`). The default implementation renders + /// the table with `comfy-table` and passes the result to [`Logger::log`]. + /// + /// # Errors + /// Returning an error will throw an exception in JavaScript. + fn table(&self, data: TableData, state: &ConsoleState, context: &mut Context) -> JsResult<()> { + let mut table = Table::new(); + table.load_preset(comfy_table::presets::UTF8_FULL); + table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + table.set_header(&data.col_names); + + for row in &data.rows { + let cells: Vec = data + .col_names + .iter() + .map(|name| Cell::new(row.get(name).cloned().unwrap_or_default())) + .collect(); + table.add_row(cells); + } + + self.log(table.to_string(), state, context) + } } /// The default implementation for logging from the console. @@ -434,10 +462,15 @@ impl Console { 0, ) .function( - console_method(Self::dir, state, logger.clone()), + console_method(Self::dir, state.clone(), logger.clone()), js_string!("dirxml"), 0, ) + .function( + console_method(Self::table, state, logger.clone()), + js_string!("table"), + 0, + ) .build() } @@ -917,4 +950,59 @@ impl Console { )?; Ok(JsValue::undefined()) } + + /// `console.table(tabularData, properties)` + /// + /// Prints a table with the columns of the properties of `tabularData` + /// (or a subset given by `properties`) and rows of `tabularData`. + /// Falls back to `console.log` if the data cannot be parsed as tabular. + /// + /// More information: + /// - [MDN documentation][mdn] + /// - [WHATWG `console` specification][spec] + /// + /// [spec]: https://console.spec.whatwg.org/#table + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/table_static + fn table( + _: &JsValue, + args: &[JsValue], + console: &Self, + logger: &impl Logger, + context: &mut Context, + ) -> JsResult { + let tabular_data = args.get_or_undefined(0); + + // Non-objects fall back to console.log. + let Some(obj) = tabular_data.as_object() else { + return Self::log(&JsValue::undefined(), args, console, logger, context); + }; + + // Validate the optional `properties` argument (must be an array if present). + let properties = match args.get(1) { + Some(props) if !props.is_undefined() => { + let obj = + props.as_object().ok_or_else(|| { + JsError::from_native(JsNativeError::typ().with_message( + "The \"properties\" argument must be an instance of Array", + )) + })?; + if !obj.is_array() { + return Err(JsError::from_native(JsNativeError::typ().with_message( + "The \"properties\" argument must be an instance of Array", + ))); + } + Some(obj.clone()) + } + _ => None, + }; + + let data = table::build_table_data(&obj, properties.as_ref(), context)?; + + match data { + Some(td) => logger.table(td, &console.state, context)?, + None => return Self::log(&JsValue::undefined(), args, console, logger, context), + } + + Ok(JsValue::undefined()) + } } diff --git a/core/runtime/src/console/table.rs b/core/runtime/src/console/table.rs new file mode 100644 index 00000000000..6500b09f033 --- /dev/null +++ b/core/runtime/src/console/table.rs @@ -0,0 +1,214 @@ +//! Data extraction helpers for `console.table()`. +//! +//! This module converts a JS value into [`TableData`] that the [`super::Logger`] +//! backend can render however it likes (terminal box-drawing, HTML, etc.). + +use boa_engine::builtins::object::OrdinaryObject; +use boa_engine::object::builtins::{JsMap, JsSet}; +use boa_engine::{Context, JsError, JsResult, JsValue, js_string, object::JsObject}; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// The column name used for row indices. +const INDEX_COL: &str = "(index)"; +/// The column name used for iteration indices (Map/Set). +const ITER_INDEX_COL: &str = "(iteration index)"; +/// The column name used for primitive (non-object) values. +const VALUE_COL: &str = "Values"; +/// The column name used for Map keys. +const KEY_COL: &str = "Key"; + +/// Structured data for `console.table()`, passed to the [`super::Logger`] so +/// each backend can render it in the most appropriate way. +#[derive(Debug, Clone)] +pub struct TableData { + /// Column headers, always starting with `"(index)"` or `"(iteration index)"`. + pub col_names: Vec, + /// Each row is a map from column name to cell value. + pub rows: Vec>, +} + +/// Try to build [`TableData`] from the first argument to `console.table()`. +/// +/// Returns `Ok(None)` when the data is not tabular (primitive, or empty +/// object/array) so the caller can fall back to `console.log`. +pub(super) fn build_table_data( + obj: &JsObject, + properties: Option<&JsObject>, + context: &mut Context, +) -> JsResult> { + // Map/Set have a fixed column layout and ignore the `properties` filter, + // matching Node.js behaviour. + let (mut data, is_collection) = if let Ok(map) = JsMap::from_object(obj.clone()) { + (extract_map_rows(&map)?, true) + } else if let Ok(set) = JsSet::from_object(obj.clone()) { + (extract_set_rows(&set)?, true) + } else { + (extract_rows(obj, context)?, false) + }; + + if data.rows.is_empty() { + return Ok(None); + } + + // Only apply the properties filter to plain objects/arrays, not Map/Set. + if !is_collection && let Some(props) = properties { + data.col_names = filter_columns(&data.col_names, props, context)?; + } + + Ok(Some(data)) +} + +/// Extracts rows from a `Map`, using `(iteration index)`, `Key`, and `Values` +/// columns to match Node.js/Chrome behaviour. +fn extract_map_rows(map: &JsMap) -> JsResult { + let col_names = vec![ + ITER_INDEX_COL.to_string(), + KEY_COL.to_string(), + VALUE_COL.to_string(), + ]; + let mut rows = Vec::new(); + let mut index = 0usize; + + map.for_each_native(|key, value| { + let mut row = FxHashMap::default(); + row.insert(ITER_INDEX_COL.to_string(), index.to_string()); + row.insert(KEY_COL.to_string(), display_cell_value(&key)); + row.insert(VALUE_COL.to_string(), display_cell_value(&value)); + rows.push(row); + index += 1; + Ok(()) + })?; + + Ok(TableData { col_names, rows }) +} + +/// Extracts rows from a `Set`, using `(iteration index)` and `Values` columns. +fn extract_set_rows(set: &JsSet) -> JsResult { + let col_names = vec![ITER_INDEX_COL.to_string(), VALUE_COL.to_string()]; + let mut rows = Vec::new(); + let mut index = 0usize; + + set.for_each_native(|value| { + let mut row = FxHashMap::default(); + row.insert(ITER_INDEX_COL.to_string(), index.to_string()); + row.insert(VALUE_COL.to_string(), display_cell_value(&value)); + rows.push(row); + index += 1; + Ok(()) + })?; + + Ok(TableData { col_names, rows }) +} + +/// Extracts rows and column names from a JS object/array. +/// +/// Only considers enumerable own string-keyed properties, matching +/// browser behaviour (equivalent to `Object.keys()`, e.g. excludes `length` on arrays). +fn extract_rows(obj: &JsObject, context: &mut Context) -> JsResult { + let keys = enumerable_keys(obj, context)?; + let mut col_names = vec![INDEX_COL.to_string()]; + let mut seen_cols: FxHashSet = FxHashSet::default(); + seen_cols.insert(INDEX_COL.to_string()); + let mut rows = Vec::new(); + + for index_str in &keys { + let val = obj.get(js_string!(index_str.as_str()), context)?; + let mut row = FxHashMap::default(); + row.insert(INDEX_COL.to_string(), index_str.clone()); + + if let Some(val_obj) = val.as_object() { + let inner_keys = enumerable_keys(&val_obj, context)?; + for col in &inner_keys { + if seen_cols.insert(col.clone()) { + col_names.push(col.clone()); + } + let cell = val_obj.get(js_string!(col.as_str()), context)?; + row.insert(col.clone(), display_cell_value(&cell)); + } + } else { + if seen_cols.insert(VALUE_COL.to_string()) { + col_names.push(VALUE_COL.to_string()); + } + row.insert(VALUE_COL.to_string(), display_cell_value(&val)); + } + + rows.push(row); + } + + Ok(TableData { col_names, rows }) +} + +/// Formats a JS value for display inside a table cell. +/// +/// Objects and arrays are rendered on a single line (e.g. `{ nested: true }` +/// instead of multi-line pretty-print), matching Node.js/Chrome behaviour +/// for nested values in `console.table`. +fn display_cell_value(val: &JsValue) -> String { + let raw = val.display().to_string(); + // If the display spans multiple lines, collapse to single-line. + if raw.contains('\n') { + raw.split('\n').map(str::trim).collect::>().join(" ") + } else { + raw + } +} + +/// Returns the enumerable own string-keyed property names of `obj`, +/// equivalent to `Object.keys(obj)`. +fn enumerable_keys(obj: &JsObject, context: &mut Context) -> JsResult> { + let keys_val = OrdinaryObject::keys( + &JsValue::undefined(), + &[JsValue::from(obj.clone())], + context, + )?; + let Some(keys_obj) = keys_val.as_object() else { + return Err(JsError::from_native( + boa_engine::JsNativeError::typ().with_message("Object.keys did not return an object"), + )); + }; + let length = keys_obj + .get(js_string!("length"), context)? + .to_length(context)?; + let mut result = Vec::with_capacity(usize::try_from(length).unwrap_or(0)); + for i in 0..length { + let val = keys_obj.get(i, context)?; + result.push(val.to_string(context)?.to_std_string_escaped()); + } + Ok(result) +} + +/// Builds a column list from the `properties` array. +/// +/// The returned list uses the **filter's order** (not discovery order), +/// and includes properties that don't exist in the data (they render as +/// empty cells). The index column is always first. Duplicates are ignored. +/// This matches Node.js behaviour. +fn filter_columns( + all_cols: &[String], + properties: &JsObject, + context: &mut Context, +) -> JsResult> { + let length = properties + .get(js_string!("length"), context)? + .to_length(context)?; + + let mut result = Vec::new(); + let mut seen = FxHashSet::default(); + + // Always include the index column first. + if let Some(idx_col) = all_cols.first() { + result.push(idx_col.clone()); + seen.insert(idx_col.clone()); + } + + // Add columns in the order specified by the properties array. + for i in 0..length { + let val = properties.get(i, context)?; + let col = val.to_string(context)?.to_std_string_escaped(); + if seen.insert(col.clone()) { + result.push(col); + } + } + + Ok(result) +} diff --git a/core/runtime/src/console/tests.rs b/core/runtime/src/console/tests.rs index 2dec834b39b..6ff1de47430 100644 --- a/core/runtime/src/console/tests.rs +++ b/core/runtime/src/console/tests.rs @@ -455,3 +455,399 @@ fn trace_with_stack_trace() { "# } ); } + +// ---- console.table tests ---- +// +// These test the `console.table()` implementation against common edge cases +// drawn from the Node.js and Bun test suites. + +/// Helper: runs JS code with a `RecordingLogger` and returns the captured log. +macro_rules! run_table_test { + ($js:expr) => {{ + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(logger.clone(), &mut context).unwrap(); + + run_test_actions_with( + [TestAction::run(TEST_HARNESS), TestAction::run($js)], + &mut context, + ); + + logger.log.borrow().clone() + }}; +} + +/// Primitives (null, undefined, false, number, string, Symbol) should fall +/// back to plain `console.log` output — no table rendered. +#[test] +fn console_table_primitives_fall_back_to_log() { + let logs = run_table_test!(indoc! {r#" + console.table(null); + console.table(undefined); + console.table(false); + console.table(42); + console.table("hello"); + console.table(Symbol("test")); + "#}); + + assert_eq!( + logs, + indoc! { r#" + null + undefined + false + 42 + hello + Symbol(test) + "# } + ); +} + +/// `console.table()` with no arguments falls back to `console.log()` with +/// no data, which outputs an empty line. +#[test] +fn console_table_no_args() { + let logs = run_table_test!("console.table();"); + assert_eq!(logs, "\n"); +} + +/// Empty array and empty object should fall back to log (no rows to tabulate). +#[test] +fn console_table_empty_collections() { + let logs = run_table_test!(indoc! {r#" + console.table([]); + "#}); + // Empty array falls back to console.log, which prints "[]" + assert!( + !logs.contains("(index)"), + "empty array should not render a table" + ); + + let logs = run_table_test!(indoc! {r#" + console.table({}); + "#}); + assert!( + !logs.contains("(index)"), + "empty object should not render a table" + ); +} + +/// Array of objects: each object's properties become columns. +#[test] +fn console_table_array_of_objects() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1, b: 2}, {a: 3, b: 4}]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a ")); + assert!(logs.contains(" b ")); + // Should NOT have a "Value" column (all elements are objects). + assert!(!logs.contains("Values")); + // Row data present. + assert!(logs.contains('0')); + assert!(logs.contains('1')); + assert!(logs.contains('3')); + assert!(logs.contains('4')); +} + +/// Properties filter restricts which columns are shown. +#[test] +fn console_table_with_properties_filter() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1, b: 2}, {a: 3, b: 4}], ["a"]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a ")); + // Value "2" from column b should not appear. + assert!( + !logs.contains(" 2 "), + "filtered column b data should be absent" + ); + assert!( + !logs.contains(" 4 "), + "filtered column b data should be absent" + ); +} + +/// Empty properties filter: only the (index) column should appear. +#[test] +fn console_table_empty_properties_filter() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1, b: 2}], []); + "#}); + + assert!(logs.contains("(index)")); + assert!(!logs.contains(" a "), "no data columns with empty filter"); + assert!(!logs.contains(" b "), "no data columns with empty filter"); +} + +/// Non-existent property in filter: only (index) column appears since "x" +/// doesn't match any actual property. +#[test] +fn console_table_nonexistent_property_filter() { + let logs = run_table_test!(indoc! {r#" + console.table({a: 1}, ["x"]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a "), "key 'a' should appear as index value"); +} + +/// Array of primitive values: shows (index) + Value columns. +#[test] +fn console_table_primitive_array() { + let logs = run_table_test!(indoc! {r#" + console.table([10, 20, 30]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains("Values")); + assert!(logs.contains("10")); + assert!(logs.contains("20")); + assert!(logs.contains("30")); +} + +/// Object with primitive values: keys become (index), values go in Value column. +#[test] +fn console_table_object_with_primitive_values() { + let logs = run_table_test!(indoc! {r#" + console.table({name: "Alice", age: 30}); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains("Values")); + assert!(logs.contains("name")); + assert!(logs.contains("age")); + assert!(logs.contains("30")); +} + +/// Object of objects: outer keys are indices, inner properties are columns. +#[test] +fn console_table_nested_objects() { + let logs = run_table_test!(indoc! {r#" + console.table({a: {x: 1, y: 2}, b: {x: 3, y: 4}}); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" x ")); + assert!(logs.contains(" y ")); + assert!(logs.contains(" a ")); + assert!(logs.contains(" b ")); + assert!( + !logs.contains("Values"), + "nested objects should not produce Values column" + ); +} + +/// Mixed array: objects and primitives together. Should have both named +/// columns (from objects) and a Value column (for primitives). +#[test] +fn console_table_mixed_array() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1}, 42]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a "), "column from object element"); + assert!( + logs.contains("Value"), + "Values column for primitive element" + ); + assert!(logs.contains('1')); + assert!(logs.contains("42")); +} + +/// Sparse columns: objects with different property sets. Missing cells +/// should be empty. +#[test] +fn console_table_sparse_columns() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1}, {b: 2}]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a ")); + assert!(logs.contains(" b ")); + assert!(logs.contains('1')); + assert!(logs.contains('2')); +} + +/// Security: using `__proto__` as a property filter must NOT cause prototype +/// pollution. +#[test] +fn console_table_proto_safety() { + // This should not throw — if __proto__ pollution occurred, the JS assertion + // would fail and the test action would panic. + let _logs = run_table_test!(indoc! {r#" + console.table([{foo: 10}, {foo: 20}], ["__proto__"]); + if ("0" in Object.prototype) { + throw new Error("prototype pollution detected!"); + } + if ("1" in Object.prototype) { + throw new Error("prototype pollution detected!"); + } + "#}); +} + +/// Map: should display with `(iteration index)`, `Key`, and `Values` columns. +#[test] +fn console_table_map() { + let logs = run_table_test!(indoc! {r#" + console.table(new Map([["a", 1], ["b", 2]])); + "#}); + + assert!(logs.contains("(iteration index)")); + assert!(logs.contains("Key")); + assert!(logs.contains("Values")); + assert!(logs.contains("\"a\"")); + assert!(logs.contains("\"b\"")); + assert!(logs.contains('1')); + assert!(logs.contains('2')); +} + +/// Set: should display with `(iteration index)` and `Values` columns. +#[test] +fn console_table_set() { + let logs = run_table_test!(indoc! {r#" + console.table(new Set([1, 2, 3])); + "#}); + + assert!(logs.contains("(iteration index)")); + assert!(logs.contains("Values")); + assert!(logs.contains('1')); + assert!(logs.contains('2')); + assert!(logs.contains('3')); + assert!(!logs.contains("Key"), "Set should not have a Key column"); +} + +/// Empty Map should fall back to console.log (no rows). +#[test] +fn console_table_empty_map() { + let logs = run_table_test!(indoc! {r#" + console.table(new Map()); + "#}); + + assert!( + !logs.contains("(iteration index)"), + "empty Map should not render a table" + ); +} + +/// `TypedArray` should work like a regular array. +#[test] +fn console_table_typed_array() { + let logs = run_table_test!(indoc! {r#" + console.table(new Uint8Array([1, 2, 3])); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains("Values")); + assert!(logs.contains('1')); + assert!(logs.contains('2')); + assert!(logs.contains('3')); +} + +/// Deeply nested objects should render inline on a single line in cells, +/// not as multi-line pretty-print. +#[test] +fn console_table_deeply_nested_inline() { + let logs = run_table_test!(indoc! {r#" + console.table({a: {x: {nested: true}}}); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" x ")); + // The nested object should be on one line, not split across multiple rows. + assert!( + logs.contains("{ nested: true }"), + "nested object should be displayed inline" + ); +} + +/// Invalid second argument (non-array) should throw a `TypeError`. +#[test] +fn console_table_invalid_properties_throws() { + let _logs = run_table_test!(indoc! {r#" + assert_throws_js(TypeError, () => { + console.table([], false); + }); + assert_throws_js(TypeError, () => { + console.table([], "bad"); + }); + assert_throws_js(TypeError, () => { + console.table([], {}); + }); + "#}); +} + +/// Duplicate entries in the properties filter should be deduplicated. +#[test] +fn console_table_duplicate_property_filter() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1, b: 2}], ["a", "b", "a"]); + "#}); + + assert!(logs.contains("(index)")); + assert!(logs.contains(" a ")); + assert!(logs.contains(" b ")); + // Count occurrences of " a " — should appear exactly twice: + // once in header, once in data separator. If duplicated, there would be more. + let a_col_count = logs.matches(" a ").count(); + assert!( + a_col_count <= 3, + "column 'a' should not be duplicated, found {a_col_count} occurrences" + ); +} + +/// The properties filter should control column *order*, matching the filter +/// array's order rather than the data's property discovery order. +#[test] +fn console_table_properties_filter_controls_order() { + let logs = run_table_test!(indoc! {r#" + console.table([{a: 1, b: 2}], ["b", "a"]); + "#}); + + // "b" should appear before "a" in the output. + let b_pos = logs.find(" b ").expect("should have column b"); + let a_pos = logs.find(" a ").expect("should have column a"); + assert!(b_pos < a_pos, "column b should appear before column a"); +} + +/// Properties that don't exist in the data should still appear as empty columns. +#[test] +fn console_table_nonexistent_property_shows_empty_column() { + let logs = run_table_test!(indoc! {r#" + console.table([1, 2], ["foo"]); + "#}); + + assert!(logs.contains("(index)")); + assert!( + logs.contains("foo"), + "nonexistent property should appear as a column" + ); +} + +/// Map should ignore the properties filter and keep its fixed column layout. +#[test] +fn console_table_map_ignores_properties_filter() { + let logs = run_table_test!(indoc! {r#" + console.table(new Map([["x", 1]]), ["a"]); + "#}); + + assert!(logs.contains("(iteration index)")); + assert!(logs.contains("Key")); + assert!(logs.contains("Values")); +} + +/// Set should ignore the properties filter and keep its fixed column layout. +#[test] +fn console_table_set_ignores_properties_filter() { + let logs = run_table_test!(indoc! {r#" + console.table(new Set([1, 2]), ["a"]); + "#}); + + assert!(logs.contains("(iteration index)")); + assert!(logs.contains("Values")); +}