From dd32789ae40181cdcd1f8016dcd1855af1fe5571 Mon Sep 17 00:00:00 2001 From: Hans Larsen Date: Tue, 10 Sep 2024 21:11:19 -0700 Subject: [PATCH] Add tests from WPT and fix them in the Console (#3979) --- core/engine/src/value/mod.rs | 33 +++- core/runtime/src/console/mod.rs | 258 ++++++++++++++++++------------ core/runtime/src/console/tests.rs | 229 +++++++++++++++++++++++++- 3 files changed, 408 insertions(+), 112 deletions(-) diff --git a/core/engine/src/value/mod.rs b/core/engine/src/value/mod.rs index a715b98a2bf..34e8e273778 100644 --- a/core/engine/src/value/mod.rs +++ b/core/engine/src/value/mod.rs @@ -21,7 +21,14 @@ use boa_profiler::Profiler; #[doc(inline)] pub use conversions::convert::Convert; -use crate::object::{JsFunction, JsPromise}; +pub(crate) use self::conversions::IntoOrUndefined; +#[doc(inline)] +pub use self::{ + conversions::try_from_js::TryFromJs, display::ValueDisplay, integer::IntegerOrInfinity, + operations::*, r#type::Type, +}; +use crate::builtins::RegExp; +use crate::object::{JsFunction, JsPromise, JsRegExp}; use crate::{ builtins::{ number::{f64_to_int32, f64_to_uint32}, @@ -35,13 +42,6 @@ use crate::{ Context, JsBigInt, JsResult, JsString, }; -pub(crate) use self::conversions::IntoOrUndefined; -#[doc(inline)] -pub use self::{ - conversions::try_from_js::TryFromJs, display::ValueDisplay, integer::IntegerOrInfinity, - operations::*, r#type::Type, -}; - mod conversions; pub(crate) mod display; mod equality; @@ -221,6 +221,23 @@ impl JsValue { .and_then(|o| JsPromise::from_object(o).ok()) } + /// Returns true if the value is a regular expression object. + #[inline] + #[must_use] + pub fn is_regexp(&self) -> bool { + matches!(self, Self::Object(obj) if obj.is::()) + } + + /// Returns the value as a regular expression if the value is a regexp, otherwise `None`. + #[inline] + #[must_use] + pub fn as_regexp(&self) -> Option { + self.as_object() + .filter(|obj| obj.is::()) + .cloned() + .and_then(|o| JsRegExp::from_object(o).ok()) + } + /// Returns true if the value is a symbol. #[inline] #[must_use] diff --git a/core/runtime/src/console/mod.rs b/core/runtime/src/console/mod.rs index bf4945b91e5..2eda34cb586 100644 --- a/core/runtime/src/console/mod.rs +++ b/core/runtime/src/console/mod.rs @@ -14,12 +14,13 @@ #[cfg(test)] mod tests; +use boa_engine::property::Attribute; use boa_engine::{ js_str, js_string, native_function::NativeFunction, object::{JsObject, ObjectInitializer}, value::{JsValue, Numeric}, - Context, JsArgs, JsData, JsError, JsResult, JsStr, JsString, + Context, JsArgs, JsData, JsError, JsResult, JsStr, JsString, JsSymbol, }; use boa_gc::{Finalize, Trace}; use rustc_hash::FxHashMap; @@ -86,9 +87,19 @@ impl Logger for DefaultLogger { /// This represents the `console` formatter. fn formatter(data: &[JsValue], context: &mut Context) -> JsResult { + fn to_string(value: &JsValue, context: &mut Context) -> JsResult { + // There is a slight difference between the standard [`JsValue::to_string`] and + // the way Console actually logs, w.r.t Symbols. + if let Some(s) = value.as_symbol() { + Ok(s.to_string()) + } else { + Ok(value.to_string(context)?.to_std_string_escaped()) + } + } + match data { [] => Ok(String::new()), - [val] => Ok(val.to_string(context)?.to_std_string_escaped()), + [val] => to_string(val, context), data => { let mut formatted = String::new(); let mut arg_index = 1; @@ -124,11 +135,27 @@ fn formatter(data: &[JsValue], context: &mut Context) -> JsResult { } /* string */ 's' => { - let arg = data - .get_or_undefined(arg_index) - .to_string(context)? - .to_std_string_escaped(); - formatted.push_str(&arg); + let arg = data.get_or_undefined(arg_index); + + // If a JS value implements `toString()`, call it. + let mut written = false; + if let Some(obj) = arg.as_object() { + if let Ok(to_string) = obj.get(js_str!("toString"), context) { + if let Some(to_string_fn) = to_string.as_function() { + let arg = to_string_fn + .call(arg, &[], context)? + .to_string(context)?; + formatted.push_str(&arg.to_std_string_escaped()); + written = true; + } + } + } + + if !written { + let arg = arg.to_string(context)?.to_std_string_escaped(); + formatted.push_str(&arg); + } + arg_index += 1; } '%' => formatted.push('%'), @@ -145,10 +172,8 @@ fn formatter(data: &[JsValue], context: &mut Context) -> JsResult { /* unformatted data */ for rest in data.iter().skip(arg_index) { - formatted.push_str(&format!( - " {}", - rest.to_string(context)?.to_std_string_escaped() - )); + formatted.push(' '); + formatted.push_str(&to_string(rest, context)?); } Ok(formatted) @@ -175,6 +200,24 @@ impl Console { /// Name of the built-in `console` property. pub const NAME: JsStr<'static> = js_str!("console"); + /// Modify the context to include the `console` object. + /// + /// # Errors + /// This function will return an error if the property cannot be defined on the global object. + pub fn register_with_logger(context: &mut Context, logger: L) -> JsResult<()> + where + L: Logger + 'static, + { + let console = Self::init_with_logger(context, logger); + context.register_global_property( + Self::NAME, + console, + Attribute::WRITABLE | Attribute::CONFIGURABLE, + )?; + + Ok(()) + } + /// Initializes the `console` with a special logger. #[allow(clippy::too_many_lines)] pub fn init_with_logger(context: &mut Context, logger: L) -> JsObject @@ -210,98 +253,107 @@ impl Console { let state = Rc::new(RefCell::new(Self::default())); let logger = Rc::new(logger); - ObjectInitializer::with_native_data(Self::default(), context) - .function( - console_method(Self::assert, state.clone(), logger.clone()), - js_string!("assert"), - 0, - ) - .function( - console_method_mut(Self::clear, state.clone(), logger.clone()), - js_string!("clear"), - 0, - ) - .function( - console_method(Self::debug, state.clone(), logger.clone()), - js_string!("debug"), - 0, - ) - .function( - console_method(Self::error, state.clone(), logger.clone()), - js_string!("error"), - 0, - ) - .function( - console_method(Self::info, state.clone(), logger.clone()), - js_string!("info"), - 0, - ) - .function( - console_method(Self::log, state.clone(), logger.clone()), - js_string!("log"), - 0, - ) - .function( - console_method(Self::trace, state.clone(), logger.clone()), - js_string!("trace"), - 0, - ) - .function( - console_method(Self::warn, state.clone(), logger.clone()), - js_string!("warn"), - 0, - ) - .function( - console_method_mut(Self::count, state.clone(), logger.clone()), - js_string!("count"), - 0, - ) - .function( - console_method_mut(Self::count_reset, state.clone(), logger.clone()), - js_string!("countReset"), - 0, - ) - .function( - console_method_mut(Self::group, state.clone(), logger.clone()), - js_string!("group"), - 0, - ) - .function( - console_method_mut(Self::group_collapsed, state.clone(), logger.clone()), - js_string!("groupCollapsed"), - 0, - ) - .function( - console_method_mut(Self::group_end, state.clone(), logger.clone()), - js_string!("groupEnd"), - 0, - ) - .function( - console_method_mut(Self::time, state.clone(), logger.clone()), - js_string!("time"), - 0, - ) - .function( - console_method(Self::time_log, state.clone(), logger.clone()), - js_string!("timeLog"), - 0, - ) - .function( - console_method_mut(Self::time_end, state.clone(), logger.clone()), - js_string!("timeEnd"), - 0, - ) - .function( - console_method(Self::dir, state.clone(), logger.clone()), - js_string!("dir"), - 0, - ) - .function( - console_method(Self::dir, state, logger.clone()), - js_string!("dirxml"), - 0, - ) - .build() + ObjectInitializer::with_native_data_and_proto( + Self::default(), + JsObject::with_object_proto(context.realm().intrinsics()), + context, + ) + .property( + JsSymbol::to_string_tag(), + Self::NAME, + Attribute::CONFIGURABLE, + ) + .function( + console_method(Self::assert, state.clone(), logger.clone()), + js_string!("assert"), + 0, + ) + .function( + console_method_mut(Self::clear, state.clone(), logger.clone()), + js_string!("clear"), + 0, + ) + .function( + console_method(Self::debug, state.clone(), logger.clone()), + js_string!("debug"), + 0, + ) + .function( + console_method(Self::error, state.clone(), logger.clone()), + js_string!("error"), + 0, + ) + .function( + console_method(Self::info, state.clone(), logger.clone()), + js_string!("info"), + 0, + ) + .function( + console_method(Self::log, state.clone(), logger.clone()), + js_string!("log"), + 0, + ) + .function( + console_method(Self::trace, state.clone(), logger.clone()), + js_string!("trace"), + 0, + ) + .function( + console_method(Self::warn, state.clone(), logger.clone()), + js_string!("warn"), + 0, + ) + .function( + console_method_mut(Self::count, state.clone(), logger.clone()), + js_string!("count"), + 0, + ) + .function( + console_method_mut(Self::count_reset, state.clone(), logger.clone()), + js_string!("countReset"), + 0, + ) + .function( + console_method_mut(Self::group, state.clone(), logger.clone()), + js_string!("group"), + 0, + ) + .function( + console_method_mut(Self::group_collapsed, state.clone(), logger.clone()), + js_string!("groupCollapsed"), + 0, + ) + .function( + console_method_mut(Self::group_end, state.clone(), logger.clone()), + js_string!("groupEnd"), + 0, + ) + .function( + console_method_mut(Self::time, state.clone(), logger.clone()), + js_string!("time"), + 0, + ) + .function( + console_method(Self::time_log, state.clone(), logger.clone()), + js_string!("timeLog"), + 0, + ) + .function( + console_method_mut(Self::time_end, state.clone(), logger.clone()), + js_string!("timeEnd"), + 0, + ) + .function( + console_method(Self::dir, state.clone(), logger.clone()), + js_string!("dir"), + 0, + ) + .function( + console_method(Self::dir, state, logger.clone()), + js_string!("dirxml"), + 0, + ) + .build() } /// Initializes the `console` built-in object. diff --git a/core/runtime/src/console/tests.rs b/core/runtime/src/console/tests.rs index ab7596a6b77..29d7a72b63a 100644 --- a/core/runtime/src/console/tests.rs +++ b/core/runtime/src/console/tests.rs @@ -1,6 +1,8 @@ use super::{formatter, Console}; use crate::test::{run_test_actions, run_test_actions_with, TestAction}; -use boa_engine::{js_string, property::Attribute, Context, JsValue}; +use crate::Logger; +use boa_engine::{js_string, property::Attribute, Context, JsError, JsResult, JsValue}; +use boa_gc::{Gc, GcRefCell}; use indoc::indoc; #[test] @@ -110,3 +112,228 @@ fn console_log_cyclic() { ); // Should not stack overflow } + +/// A logger that records all log messages. +#[derive(Clone, Debug, Default, boa_engine::Trace, boa_engine::Finalize)] +struct RecordingLogger { + log: Gc>, +} + +impl Logger for RecordingLogger { + fn log(&self, msg: String, state: &Console) -> JsResult<()> { + use std::fmt::Write; + let indent = 2 * state.groups.len(); + writeln!(self.log.borrow_mut(), "{msg:>indent$}").map_err(JsError::from_rust) + } + + fn info(&self, msg: String, state: &Console) -> JsResult<()> { + self.log(msg, state) + } + + fn warn(&self, msg: String, state: &Console) -> JsResult<()> { + self.log(msg, state) + } + + fn error(&self, msg: String, state: &Console) -> JsResult<()> { + self.log(msg, state) + } +} + +/// Harness methods to be used in JS tests. +const TEST_HARNESS: &str = r#" +function assert_true(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} +function assert_own_property(obj, prop) { + assert_true( + Object.prototype.hasOwnProperty.call(obj, prop), + `Expected ${prop.toString()} to be an own property`, + ); +} +function assert_equals(actual, expected, message) { + assert_true( + actual === expected, + `${message} (actual: ${actual.toString()}, expected: ${expected.toString()})`, + ); +} +function assert_throws_js(error, func) { + try { + func(); + } catch (e) { + if (e instanceof error) { + return; + } + throw new Error(`Expected ${error.name} to be thrown, but got ${e.name}`); + } + throw new Error(`Expected ${error.name} to be thrown, but no exception was thrown`); +} + +// To keep the tests as close to the WPT tests as possible, we define `self` to +// be `globalThis`. +const self = globalThis; +"#; + +/// The WPT test `console/console-log-symbol.any.js`. +#[test] +fn wpt_log_symbol_any() { + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(&mut context, logger.clone()).unwrap(); + + run_test_actions_with( + [ + TestAction::run(TEST_HARNESS), + TestAction::run(indoc! {r#" + console.log(Symbol()); + console.log(Symbol("abc")); + console.log(Symbol.for("def")); + console.log(Symbol.isConcatSpreadable); + "#}), + ], + &mut context, + ); + + let logs = logger.log.borrow().clone(); + assert_eq!( + logs, + indoc! { r#" + Symbol() + Symbol(abc) + Symbol(def) + Symbol(Symbol.isConcatSpreadable) + "# } + ); +} + +/// The WPT test `console/console-is-a-namespace.any.js`. +#[test] +fn wpt_console_is_a_namespace() { + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(&mut context, logger.clone()).unwrap(); + + run_test_actions_with( + [ + TestAction::run(TEST_HARNESS), + // console exists on the global object + TestAction::run(indoc! {r#" + assert_true(globalThis.hasOwnProperty("console")); + "#}), + // console has the right property descriptors + TestAction::run(indoc! {r#" + const propDesc = Object.getOwnPropertyDescriptor(self, "console"); + assert_equals(propDesc.writable, true, "must be writable"); + assert_equals(propDesc.enumerable, false, "must not be enumerable"); + assert_equals(propDesc.configurable, true, "must be configurable"); + assert_equals(propDesc.value, console, "must have the right value"); + "#}), + // The prototype chain must be correct + TestAction::run(indoc! {r#" + const prototype1 = Object.getPrototypeOf(console); + const prototype2 = Object.getPrototypeOf(prototype1); + + assert_equals(Object.getOwnPropertyNames(prototype1).length, 0, "The [[Prototype]] must have no properties"); + assert_equals(prototype2, Object.prototype, "The [[Prototype]]'s [[Prototype]] must be %ObjectPrototype%"); + "#}), + ], + &mut context, + ); +} + +/// The WPT test `console/console-label-conversion.any.js`. +#[test] +fn wpt_console_label_conversion() { + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(&mut context, logger.clone()).unwrap(); + + run_test_actions_with( + [ + TestAction::run(TEST_HARNESS), + TestAction::run(indoc! {r#" + const methods = ['count', 'countReset', 'time', 'timeLog', 'timeEnd']; + "#}), + // console.${method}()'s label gets converted to string via label.toString() when label is an object + TestAction::run(indoc! {r#" + for (const method of methods) { + let labelToStringCalled = false; + + console[method]({ + toString() { + labelToStringCalled = true; + } + }); + + assert_true(labelToStringCalled, `${method}() must call toString() on label when label is an object`); + } + "#}), + // ${method} must re-throw any exceptions thrown by label.toString() conversion + TestAction::run(indoc! {r#" + for (const method of methods) { + assert_throws_js(Error, () => { + console[method]({ + toString() { + throw new Error('conversion error'); + } + }); + }); + } + "#}), + ], + &mut context, + ); +} + +/// The WPT test `console/console-namespace-object-class-string.any.js`. +#[test] +fn console_namespace_object_class_string() { + let mut context = Context::default(); + let logger = RecordingLogger::default(); + Console::register_with_logger(&mut context, logger.clone()).unwrap(); + + run_test_actions_with( + [ + TestAction::run(TEST_HARNESS), + // @@toStringTag exists on the namespace object with the appropriate descriptor + TestAction::run(indoc! {r#" + assert_own_property(console, Symbol.toStringTag); + + const propDesc = Object.getOwnPropertyDescriptor(console, Symbol.toStringTag); + assert_equals(propDesc.value, "console", "value"); + assert_equals(propDesc.writable, false, "writable"); + assert_equals(propDesc.enumerable, false, "enumerable"); + assert_equals(propDesc.configurable, true, "configurable"); + "#}), + // Object.prototype.toString applied to the namespace object + TestAction::run(indoc! {r#" + assert_equals(console.toString(), "[object console]"); + assert_equals(Object.prototype.toString.call(console), "[object console]"); + "#}), + // Object.prototype.toString applied after modifying the namespace object's @@toStringTag + TestAction::run(indoc! {r#" + assert_own_property(console, Symbol.toStringTag, "Precondition: @@toStringTag on the namespace object"); + // t.add_cleanup(() => { + // Object.defineProperty(console, Symbol.toStringTag, { value: "console" }); + // }); + + Object.defineProperty(console, Symbol.toStringTag, { value: "Test" }); + assert_equals(console.toString(), "[object Test]"); + assert_equals(Object.prototype.toString.call(console), "[object Test]"); + "#}), + // Object.prototype.toString applied after deleting @@toStringTag + TestAction::run(indoc! {r#" + assert_own_property(console, Symbol.toStringTag, "Precondition: @@toStringTag on the namespace object"); + // t.add_cleanup(() => { + // Object.defineProperty(console, Symbol.toStringTag, { value: "console" }); + // }); + + assert_true(delete console[Symbol.toStringTag]); + assert_equals(console.toString(), "[object Object]"); + assert_equals(Object.prototype.toString.call(console), "[object Object]"); + "#}), + ], + &mut context, + ); +}