From aebc641137349c83d1b4ad3a834d8daa540dfcad Mon Sep 17 00:00:00 2001 From: kaustubhOG Date: Mon, 29 Jun 2026 03:08:47 +0530 Subject: [PATCH] feat(wintertc): migrate atob/btoa from boa_runtime Move the base64 module (atob and btoa) into the boa_wintertc crate as the first step of migrating the existing Web APIs into the dedicated TC55 crate. The implementation is moved unchanged; the only difference from the boa_runtime version is a short TC55 status note added to the module documentation. boa_runtime now depends on boa_wintertc and consumes atob/btoa from it rather than keeping a duplicate implementation: core/runtime/src/base64 is removed, boa_runtime::base64 becomes a re-export of boa_wintertc::base64, and Base64Extension delegates to boa_wintertc::base64::register. The public API of boa_runtime is unchanged, and the base64 crate dependency is dropped from boa_runtime since the module was its only user. The test harness (TestAction and run_test_actions_with) is ported into boa_wintertc so the migrated unit tests run unchanged. All six base64 unit tests pass. --- Cargo.lock | 6 +- Cargo.toml | 1 + core/runtime/Cargo.toml | 2 +- core/runtime/src/base64/mod.rs | 82 ------ core/runtime/src/extensions.rs | 2 +- core/runtime/src/lib.rs | 8 +- core/wintertc/Cargo.toml | 6 + core/wintertc/src/base64/mod.rs | 91 +++++-- .../{runtime => wintertc}/src/base64/tests.rs | 0 core/wintertc/src/lib.rs | 233 ++++++++++++++++++ 10 files changed, 331 insertions(+), 100 deletions(-) delete mode 100644 core/runtime/src/base64/mod.rs rename core/{runtime => wintertc}/src/base64/tests.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 98504140d11..2cf8535754a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,9 +565,9 @@ dependencies = [ name = "boa_runtime" version = "1.0.0-dev" dependencies = [ - "base64", "boa_engine", "boa_gc", + "boa_wintertc", "bus", "bytemuck", "comfy-table", @@ -634,7 +634,11 @@ dependencies = [ name = "boa_wintertc" version = "1.0.0-dev" dependencies = [ + "base64", "boa_engine", + "futures-lite", + "indoc", + "textwrap", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b24b759de0c..178108a742e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ boa_macros = { version = "~1.0.0-dev", path = "core/macros" } boa_parser = { version = "~1.0.0-dev", path = "core/parser" } boa_runtime = { version = "~1.0.0-dev", path = "core/runtime" } boa_string = { version = "~1.0.0-dev", path = "core/string" } +boa_wintertc = { version = "~1.0.0-dev", path = "core/wintertc" } # Utility Repo Crates tag_ptr = { version = "~0.1.0", path = "utils/tag_ptr" } diff --git a/core/runtime/Cargo.toml b/core/runtime/Cargo.toml index d276b06fe75..cea4f11abf9 100644 --- a/core/runtime/Cargo.toml +++ b/core/runtime/Cargo.toml @@ -12,8 +12,8 @@ rust-version.workspace = true [dependencies] boa_engine.workspace = true -base64.workspace = true boa_gc.workspace = true +boa_wintertc.workspace = true bus = { workspace = true, optional = true } bytemuck.workspace = true either = { workspace = true, optional = true } diff --git a/core/runtime/src/base64/mod.rs b/core/runtime/src/base64/mod.rs deleted file mode 100644 index 3d166573fc3..00000000000 --- a/core/runtime/src/base64/mod.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Base64 utility methods (`atob` and `btoa`). -//! -//! See . -#![allow(clippy::needless_pass_by_value)] - -use boa_engine::realm::Realm; -use boa_engine::{Context, JsResult, boa_module}; - -#[cfg(test)] -mod tests; - -/// A forgiving Base64 engine that accepts input with or without padding, -/// matching the [forgiving-base64 decode](https://infra.spec.whatwg.org/#forgiving-base64-decode) -/// algorithm used by `atob`. -const FORGIVING: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new( - &base64::alphabet::STANDARD, - base64::engine::general_purpose::GeneralPurposeConfig::new() - .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), -); - -/// JavaScript module containing the `atob` and `btoa` functions. -#[boa_module] -pub mod js_module { - use super::FORGIVING; - use base64::Engine as _; - use boa_engine::{JsResult, js_error}; - - /// The [`btoa()`][mdn] method creates a Base64-encoded ASCII string from - /// a binary string (i.e., a string in which each character is treated as - /// a byte of binary data). - /// - /// # Errors - /// Throws a `DOMException` (`InvalidCharacterError`) if the string - /// contains any character whose code point is greater than `0xFF`. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa - #[allow(clippy::cast_possible_truncation)] - pub fn btoa(data: String) -> JsResult { - let bytes: Vec = data - .chars() - .map(|c| { - let cp = c as u32; - if cp > 0xFF { - Err(js_error!("InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.")) - } else { - Ok(cp as u8) - } - }) - .collect::>>()?; - - Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) - } - - /// The [`atob()`][mdn] method decodes a string of data which has been - /// encoded using Base64 encoding. - /// - /// # Errors - /// Throws a `DOMException` (`InvalidCharacterError`) if the input is - /// not valid Base64. - /// - /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/atob - pub fn atob(data: String) -> JsResult { - let cleaned: String = data - .chars() - .filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r')) - .collect(); - - let bytes = FORGIVING.decode(cleaned.as_bytes()).map_err(|_| { - js_error!("InvalidCharacterError: The string to be decoded is not correctly encoded.") - })?; - - Ok(bytes.into_iter().map(char::from).collect()) - } -} - -/// Register the `atob` and `btoa` functions in the global context. -/// -/// # Errors -/// Returns an error if the functions cannot be registered. -pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { - js_module::boa_register(realm, context) -} diff --git a/core/runtime/src/extensions.rs b/core/runtime/src/extensions.rs index bc760264baa..f271bd0afe7 100644 --- a/core/runtime/src/extensions.rs +++ b/core/runtime/src/extensions.rs @@ -64,7 +64,7 @@ pub struct Base64Extension; impl RuntimeExtension for Base64Extension { fn register(self, realm: Option, context: &mut Context) -> JsResult<()> { - crate::base64::register(realm, context) + boa_wintertc::base64::register(realm, context) } } diff --git a/core/runtime/src/lib.rs b/core/runtime/src/lib.rs index c2e6e24968c..e1e1ec2869e 100644 --- a/core/runtime/src/lib.rs +++ b/core/runtime/src/lib.rs @@ -105,9 +105,15 @@ clippy::let_unit_value )] -pub mod base64; pub mod console; +/// Base64 utility methods (`atob` and `btoa`), re-exported from [`boa_wintertc`]. +/// +/// This API is part of the `WinterTC` (TC55) Minimum Common Web API and is implemented in +/// `boa_wintertc`. It is re-exported here so `boa_runtime` users keep a single import path. +#[doc(inline)] +pub use boa_wintertc::base64; + #[doc(inline)] pub use console::{Console, ConsoleState, DefaultLogger, Logger, NullLogger}; diff --git a/core/wintertc/Cargo.toml b/core/wintertc/Cargo.toml index 66ffe461e47..b661013a61c 100644 --- a/core/wintertc/Cargo.toml +++ b/core/wintertc/Cargo.toml @@ -12,6 +12,12 @@ rust-version.workspace = true [dependencies] boa_engine.workspace = true +base64.workspace = true + +[dev-dependencies] +indoc.workspace = true +textwrap.workspace = true +futures-lite.workspace = true [lints] workspace = true diff --git a/core/wintertc/src/base64/mod.rs b/core/wintertc/src/base64/mod.rs index 495dd1ce4b8..8170100e1db 100644 --- a/core/wintertc/src/base64/mod.rs +++ b/core/wintertc/src/base64/mod.rs @@ -1,23 +1,86 @@ -//! TC55 `atob` / `btoa` implementation. +//! Base64 utility methods (`atob` and `btoa`). //! -//! Spec: +//! See . //! //! # TC55 Status //! //! `atob` and `btoa` are required in the `WinterTC` TC55 Minimum Common Web API. -//! -//! # TODO -//! -//! - Migrate `atob` and `btoa` from `boa_runtime::base64`. +#![allow(clippy::needless_pass_by_value)] + +use boa_engine::realm::Realm; +use boa_engine::{Context, JsResult, boa_module}; + +#[cfg(test)] +mod tests; + +/// A forgiving Base64 engine that accepts input with or without padding, +/// matching the [forgiving-base64 decode](https://infra.spec.whatwg.org/#forgiving-base64-decode) +/// algorithm used by `atob`. +const FORGIVING: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new( + &base64::alphabet::STANDARD, + base64::engine::general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent), +); + +/// JavaScript module containing the `atob` and `btoa` functions. +#[boa_module] +pub mod js_module { + use super::FORGIVING; + use base64::Engine as _; + use boa_engine::{JsResult, js_error}; + + /// The [`btoa()`][mdn] method creates a Base64-encoded ASCII string from + /// a binary string (i.e., a string in which each character is treated as + /// a byte of binary data). + /// + /// # Errors + /// Throws a `DOMException` (`InvalidCharacterError`) if the string + /// contains any character whose code point is greater than `0xFF`. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa + #[allow(clippy::cast_possible_truncation)] + pub fn btoa(data: String) -> JsResult { + let bytes: Vec = data + .chars() + .map(|c| { + let cp = c as u32; + if cp > 0xFF { + Err(js_error!("InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range.")) + } else { + Ok(cp as u8) + } + }) + .collect::>>()?; -/// Register `atob` and `btoa` into the given context. + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) + } + + /// The [`atob()`][mdn] method decodes a string of data which has been + /// encoded using Base64 encoding. + /// + /// # Errors + /// Throws a `DOMException` (`InvalidCharacterError`) if the input is + /// not valid Base64. + /// + /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/atob + pub fn atob(data: String) -> JsResult { + let cleaned: String = data + .chars() + .filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r')) + .collect(); + + let bytes = FORGIVING.decode(cleaned.as_bytes()).map_err(|_| { + js_error!("InvalidCharacterError: The string to be decoded is not correctly encoded.") + })?; + + Ok(bytes.into_iter().map(char::from).collect()) + } +} + +/// Register the `atob` and `btoa` functions in the global context. /// /// # Errors -/// -/// Returns a [`boa_engine::JsError`] if registration fails. -pub fn register( - _realm: Option, - _ctx: &mut boa_engine::Context, -) -> boa_engine::JsResult<()> { - Ok(()) +/// Returns an error if the functions cannot be registered. +pub fn register(realm: Option, context: &mut Context) -> JsResult<()> { + js_module::boa_register(realm, context) } diff --git a/core/runtime/src/base64/tests.rs b/core/wintertc/src/base64/tests.rs similarity index 100% rename from core/runtime/src/base64/tests.rs rename to core/wintertc/src/base64/tests.rs diff --git a/core/wintertc/src/lib.rs b/core/wintertc/src/lib.rs index eb518e436fa..17c86bb625b 100644 --- a/core/wintertc/src/lib.rs +++ b/core/wintertc/src/lib.rs @@ -76,3 +76,236 @@ pub fn register( Ok(()) } + +#[cfg(test)] +pub(crate) mod test { + use crate::register; + use boa_engine::{Context, JsError, JsResult, JsValue, Source, builtins}; + use std::borrow::Cow; + use std::path::{Path, PathBuf}; + use std::pin::Pin; + + /// A test action executed in a test function. + #[allow(missing_debug_implementations)] + pub(crate) struct TestAction(Inner); + + #[allow(dead_code)] + #[allow(clippy::type_complexity)] + enum Inner { + RunHarness, + Run { + source: Cow<'static, str>, + }, + RunFile { + path: PathBuf, + }, + RunJobs, + InspectContext { + op: Box, + }, + InspectContextAsync { + op: Box FnOnce(&'a mut Context) -> Pin + 'a>>>, + }, + Assert { + source: Cow<'static, str>, + }, + AssertEq { + source: Cow<'static, str>, + expected: JsValue, + }, + AssertWithOp { + source: Cow<'static, str>, + op: fn(JsValue, &mut Context) -> bool, + }, + AssertOpaqueError { + source: Cow<'static, str>, + expected: JsValue, + }, + AssertNativeError { + source: Cow<'static, str>, + kind: builtins::error::ErrorKind, + message: &'static str, + }, + AssertContext { + op: fn(&mut Context) -> bool, + }, + } + + impl TestAction { + #[allow(unused)] + pub(crate) fn harness() -> Self { + Self(Inner::RunHarness) + } + + /// Runs `source`, panicking if the execution throws. + pub(crate) fn run(source: impl Into>) -> Self { + Self(Inner::Run { + source: source.into(), + }) + } + + /// Executes `op` with the currently active context. + /// + /// Useful to make custom assertions that must be done from Rust code. + #[allow(unused)] + pub(crate) fn inspect_context(op: impl FnOnce(&mut Context) + 'static) -> Self { + Self(Inner::InspectContext { op: Box::new(op) }) + } + + /// Executes `op` with the currently active context in an async environment. + #[allow(unused)] + pub(crate) fn inspect_context_async(op: impl AsyncFnOnce(&mut Context) + 'static) -> Self { + Self(Inner::InspectContextAsync { + op: Box::new(move |ctx| Box::pin(op(ctx))), + }) + } + } + + /// Executes a list of test actions on a new, default context with all TC55 APIs registered. + #[track_caller] + #[allow(unused)] + pub(crate) fn run_test_actions(actions: impl IntoIterator) { + let context = &mut Context::default(); + register(None, context).expect("failed to register WinterTC APIs"); + run_test_actions_with(actions, context); + } + + /// Executes a list of test actions on the provided context. + #[track_caller] + #[allow(clippy::too_many_lines, clippy::missing_panics_doc)] + pub(crate) fn run_test_actions_with( + actions: impl IntoIterator, + context: &mut Context, + ) { + #[track_caller] + fn forward_val(context: &mut Context, source: &str) -> JsResult { + context.eval(Source::from_bytes(source)) + } + + #[track_caller] + fn forward_file(context: &mut Context, path: impl AsRef) -> JsResult { + let p = path.as_ref(); + context.eval(Source::from_filepath(p).map_err(JsError::from_rust)?) + } + + #[track_caller] + fn fmt_test(source: &str, test: usize) -> String { + format!( + "\n\nTest case {test}: \n```\n{}\n```", + textwrap::indent(source, " ") + ) + } + + // Some unwrapping patterns look weird because they're replaceable + // by simpler patterns like `unwrap_or_else` or `unwrap_err + let mut i = 1; + for action in actions.into_iter().map(|a| a.0) { + match action { + Inner::RunHarness => { + if let Err(e) = forward_file(context, "./assets/harness.js") { + panic!("Uncaught {e} in the test harness"); + } + } + Inner::Run { source } => { + if let Err(e) = forward_val(context, &source) { + panic!("{}\nUncaught {e}", fmt_test(&source, i)); + } + } + Inner::RunFile { path } => { + if let Err(e) = forward_file(context, &path) { + panic!("Uncaught {e} in file {path:?}"); + } + } + Inner::RunJobs => { + if let Err(e) = context.run_jobs() { + panic!("Uncaught {e} in a job"); + } + } + Inner::InspectContext { op } => { + op(context); + } + Inner::InspectContextAsync { op } => futures_lite::future::block_on(op(context)), + Inner::Assert { source } => { + let val = match forward_val(context, &source) { + Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), + Ok(v) => v, + }; + let Some(val) = val.as_boolean() else { + panic!( + "{}\nTried to assert with the non-boolean value `{}`", + fmt_test(&source, i), + val.display() + ) + }; + assert!(val, "{}", fmt_test(&source, i)); + i += 1; + } + Inner::AssertEq { source, expected } => { + let val = match forward_val(context, &source) { + Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), + Ok(v) => v, + }; + assert_eq!(val, expected, "{}", fmt_test(&source, i)); + i += 1; + } + Inner::AssertWithOp { source, op } => { + let val = match forward_val(context, &source) { + Err(e) => panic!("{}\nUncaught {e}", fmt_test(&source, i)), + Ok(v) => v, + }; + assert!(op(val, context), "{}", fmt_test(&source, i)); + i += 1; + } + Inner::AssertOpaqueError { source, expected } => { + let err = match forward_val(context, &source) { + Ok(v) => panic!( + "{}\nExpected error, got value `{}`", + fmt_test(&source, i), + v.display() + ), + Err(e) => e, + }; + let Some(err) = err.as_opaque() else { + panic!( + "{}\nExpected opaque error, got native error `{}`", + fmt_test(&source, i), + err + ) + }; + + assert_eq!(err, &expected, "{}", fmt_test(&source, i)); + i += 1; + } + Inner::AssertNativeError { + source, + kind, + message, + } => { + let err = match forward_val(context, &source) { + Ok(v) => panic!( + "{}\nExpected error, got value `{}`", + fmt_test(&source, i), + v.display() + ), + Err(e) => e, + }; + let native = match err.try_native(context) { + Ok(err) => err, + Err(e) => panic!( + "{}\nCouldn't obtain a native error: {e}", + fmt_test(&source, i) + ), + }; + + assert_eq!(native.kind(), &kind, "{}", fmt_test(&source, i)); + assert_eq!(native.message(), message, "{}", fmt_test(&source, i)); + i += 1; + } + Inner::AssertContext { op } => { + assert!(op(context), "Test case {i}"); + i += 1; + } + } + } + } +}