diff --git a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h index 9093dfdc530..242a8acdd3b 100644 --- a/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h +++ b/Userland/Libraries/LibJS/Runtime/CommonPropertyNames.h @@ -212,6 +212,7 @@ namespace JS { P(freeze) \ P(from) \ P(fromAsync) \ + P(fromBase64) \ P(fromCharCode) \ P(fromCodePoint) \ P(fromEntries) \ @@ -338,6 +339,7 @@ namespace JS { P(language) \ P(languageDisplay) \ P(largestUnit) \ + P(lastChunkHandling) \ P(lastIndex) \ P(lastIndexOf) \ P(length) \ diff --git a/Userland/Libraries/LibJS/Runtime/TypedArray.cpp b/Userland/Libraries/LibJS/Runtime/TypedArray.cpp index adf826b6009..79bf708e4d4 100644 --- a/Userland/Libraries/LibJS/Runtime/TypedArray.cpp +++ b/Userland/Libraries/LibJS/Runtime/TypedArray.cpp @@ -527,6 +527,9 @@ void TypedArrayBase::visit_edges(Visitor& visitor) define_direct_property(vm.names.BYTES_PER_ELEMENT, Value((i32)sizeof(Type)), 0); \ \ define_direct_property(vm.names.length, Value(3), Attribute::Configurable); \ + \ + if constexpr (IsSame) \ + Uint8ArrayConstructorHelpers::initialize(realm, *this); \ } \ \ /* 23.2.5.1 TypedArray ( ...args ), https://tc39.es/ecma262/#sec-typedarray */ \ diff --git a/Userland/Libraries/LibJS/Runtime/Uint8Array.cpp b/Userland/Libraries/LibJS/Runtime/Uint8Array.cpp index 0200f07a75c..bb1791bb822 100644 --- a/Userland/Libraries/LibJS/Runtime/Uint8Array.cpp +++ b/Userland/Libraries/LibJS/Runtime/Uint8Array.cpp @@ -14,6 +14,14 @@ namespace JS { +void Uint8ArrayConstructorHelpers::initialize(Realm& realm, Object& constructor) +{ + auto& vm = constructor.vm(); + + static constexpr u8 attr = Attribute::Writable | Attribute::Configurable; + constructor.define_native_function(realm, vm.names.fromBase64, from_base64, 1, attr); +} + void Uint8ArrayPrototypeHelpers::initialize(Realm& realm, Object& prototype) { auto& vm = prototype.vm(); @@ -43,6 +51,28 @@ static ThrowCompletionOr parse_alphabet(VM& vm, Object& options) return vm.throw_completion(ErrorType::OptionIsNotValidValue, alphabet, "alphabet"sv); } +static ThrowCompletionOr parse_last_chunk_handling(VM& vm, Object& options) +{ + // Let lastChunkHandling be ? Get(opts, "lastChunkHandling"). + auto last_chunk_handling = TRY(options.get(vm.names.lastChunkHandling)); + + // If lastChunkHandling is undefined, set lastChunkHandling to "loose". + if (last_chunk_handling.is_undefined()) + return LastChunkHandling::Loose; + + // If lastChunkHandling is not one of "loose", "strict", or "stop-before-partial", throw a TypeError exception. + if (last_chunk_handling.is_string()) { + if (last_chunk_handling.as_string().utf8_string_view() == "loose"sv) + return LastChunkHandling::Loose; + if (last_chunk_handling.as_string().utf8_string_view() == "strict"sv) + return LastChunkHandling::Strict; + if (last_chunk_handling.as_string().utf8_string_view() == "stop-before-partial"sv) + return LastChunkHandling::StopBeforePartial; + } + + return vm.throw_completion(ErrorType::OptionIsNotValidValue, last_chunk_handling, "lastChunkHandling"sv); +} + // 1 Uint8Array.prototype.toBase64 ( [ options ] ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.prototype.tobase64 JS_DEFINE_NATIVE_FUNCTION(Uint8ArrayPrototypeHelpers::to_base64) { @@ -112,6 +142,57 @@ JS_DEFINE_NATIVE_FUNCTION(Uint8ArrayPrototypeHelpers::to_hex) return PrimitiveString::create(vm, MUST(out.to_string())); } +// 3 Uint8Array.fromBase64 ( string [ , options ] ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-uint8array.frombase64 +JS_DEFINE_NATIVE_FUNCTION(Uint8ArrayConstructorHelpers::from_base64) +{ + auto& realm = *vm.current_realm(); + + auto string_value = vm.argument(0); + auto options_value = vm.argument(1); + + // 1. If string is not a String, throw a TypeError exception. + if (!string_value.is_string()) + return vm.throw_completion(ErrorType::NotAString, string_value); + + // 2. Let opts be ? GetOptionsObject(options). + auto* options = TRY(Temporal::get_options_object(vm, options_value)); + + // 3. Let alphabet be ? Get(opts, "alphabet"). + // 4. If alphabet is undefined, set alphabet to "base64". + // 5. If alphabet is neither "base64" nor "base64url", throw a TypeError exception. + auto alphabet = TRY(parse_alphabet(vm, *options)); + + // 6. Let lastChunkHandling be ? Get(opts, "lastChunkHandling"). + // 7. If lastChunkHandling is undefined, set lastChunkHandling to "loose". + // 8. If lastChunkHandling is not one of "loose", "strict", or "stop-before-partial", throw a TypeError exception. + auto last_chunk_handling = TRY(parse_last_chunk_handling(vm, *options)); + + // 9. Let result be FromBase64(string, alphabet, lastChunkHandling). + auto result = JS::from_base64(vm, string_value.as_string().utf8_string_view(), alphabet, last_chunk_handling); + + // 10. If result.[[Error]] is not none, then + if (result.error.has_value()) { + // a. Throw result.[[Error]]. + return result.error.release_value(); + } + + // 11. Let resultLength be the length of result.[[Bytes]]. + auto result_length = result.bytes.size(); + + // 12. Let ta be ? AllocateTypedArray("Uint8Array", %Uint8Array%, "%Uint8Array.prototype%", resultLength). + auto typed_array = TRY(Uint8Array::create(realm, result_length)); + + // 13. Set the value at each index of ta.[[ViewedArrayBuffer]].[[ArrayBufferData]] to the value at the corresponding + // index of result.[[Bytes]]. + auto& array_buffer_data = typed_array->viewed_array_buffer()->buffer(); + + for (size_t index = 0; index < result_length; ++index) + array_buffer_data[index] = result.bytes[index]; + + // 14. Return ta. + return typed_array; +} + // 7 ValidateUint8Array ( ta ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-validateuint8array ThrowCompletionOr> validate_uint8_array(VM& vm) { @@ -170,4 +251,337 @@ ThrowCompletionOr get_uint8_array_bytes(VM& vm, TypedArrayBase const return bytes; } +// 10.1 SkipAsciiWhitespace ( string, index ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-skipasciiwhitespace +static size_t skip_ascii_whitespace(StringView string, size_t index) +{ + // 1. Let length be the length of string. + auto length = string.length(); + + // 2. Repeat, while index < length, + while (index < length) { + // a. Let char be the code unit at index index of string. + auto ch = string[index]; + + // b. If char is neither 0x0009 (TAB), 0x000A (LF), 0x000C (FF), 0x000D (CR), nor 0x0020 (SPACE), then + if (ch != '\t' && ch != '\n' && ch != '\f' && ch != '\r' && ch != ' ') { + // i. Return index. + return index; + } + + // c. Set index to index + 1. + ++index; + } + + // 3. Return index. + return index; +} + +// 10.2 DecodeBase64Chunk ( chunk [ , throwOnExtraBits ] ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-frombase64 +static ThrowCompletionOr decode_base64_chunk(VM& vm, StringBuilder& chunk, Optional throw_on_extra_bits = {}) +{ + // 1. Let chunkLength be the length of chunk. + auto chunk_length = chunk.length(); + + // 2. If chunkLength is 2, then + if (chunk_length == 2) { + // a. Set chunk to the string-concatenation of chunk and "AA". + chunk.append("AA"sv); + } + // 3. Else if chunkLength is 3, then + else if (chunk_length == 3) { + // a. Set chunk to the string-concatenation of chunk and "A". + chunk.append("A"sv); + } + // 4. Else, + else { + // a. Assert: chunkLength is 4. + VERIFY(chunk_length == 4); + } + + // 5. Let byteSequence be the unique sequence of 3 bytes resulting from decoding chunk as base64 (such that applying + // the base64 encoding specified in section 4 of RFC 4648 to byteSequence would produce chunk). + // 6. Let bytes be a List whose elements are the elements of byteSequence, in order. + auto bytes = MUST(decode_base64(chunk.string_view())); + + // 7. If chunkLength is 2, then + if (chunk_length == 2) { + // a. Assert: throwOnExtraBits is present. + VERIFY(throw_on_extra_bits.has_value()); + + // b. If throwOnExtraBits is true and bytes[1] ≠ 0, then + if (*throw_on_extra_bits && bytes[1] != 0) { + // i. Throw a SyntaxError exception. + return vm.throw_completion("Extra bits found at end of chunk"sv); + } + + // c. Return « bytes[0] ». + return MUST(bytes.slice(0, 1)); + } + + // 8. Else if chunkLength is 3, then + if (chunk_length == 3) { + // a. Assert: throwOnExtraBits is present. + VERIFY(throw_on_extra_bits.has_value()); + + // b. If throwOnExtraBits is true and bytes[2] ≠ 0, then + if (*throw_on_extra_bits && bytes[2] != 0) { + // i. Throw a SyntaxError exception. + return vm.throw_completion("Extra bits found at end of chunk"sv); + } + + // c. Return « bytes[0], bytes[1] ». + return MUST(bytes.slice(0, 2)); + } + + // 9. Else, + // a. Return bytes. + return bytes; +} + +// 10.3 FromBase64 ( string, alphabet, lastChunkHandling [ , maxLength ] ), https://tc39.es/proposal-arraybuffer-base64/spec/#sec-frombase64 +DecodeResult from_base64(VM& vm, StringView string, Alphabet alphabet, LastChunkHandling last_chunk_handling, Optional max_length) +{ + // FIXME: We can only use simdutf when the last-chunk-handling parameter is "loose". Upstream is planning to implement + // the remaining options. When that is complete, we should be able to remove the slow implementation below. See: + // https://github.com/simdutf/simdutf/issues/440 + if (last_chunk_handling == LastChunkHandling::Loose) { + auto output = MUST(ByteBuffer::create_uninitialized(max_length.value_or_lazy_evaluated([&]() { + return AK::size_required_to_decode_base64(string); + }))); + + auto result = alphabet == Alphabet::Base64 + ? AK::decode_base64_into(string, output) + : AK::decode_base64url_into(string, output); + + if (result.is_error()) { + auto error = vm.throw_completion(result.error().error.string_literal()); + return { .read = result.error().valid_input_bytes, .bytes = move(output), .error = move(error) }; + } + + return { .read = result.value(), .bytes = move(output), .error = {} }; + } + + // 1. If maxLength is not present, then + if (!max_length.has_value()) { + // a. Let maxLength be 2**53 - 1. + max_length = MAX_ARRAY_LIKE_INDEX; + + // b. NOTE: Because the input is a string, the length of strings is limited to 2**53 - 1 characters, and the + // output requires no more bytes than the input has characters, this limit can never be reached. However, it + // is editorially convenient to use a finite value here. + } + + // 2. NOTE: The order of validation and decoding in the algorithm below is not observable. Implementations are + // encouraged to perform them in whatever order is most efficient, possibly interleaving validation with decoding, + // as long as the behaviour is observably equivalent. + + // 3. If maxLength is 0, then + if (max_length == 0uz) { + // a. Return the Record { [[Read]]: 0, [[Bytes]]: « », [[Error]]: none }. + return { .read = 0, .bytes = {}, .error = {} }; + } + + // 4. Let read be 0. + size_t read = 0; + + // 5. Let bytes be « ». + ByteBuffer bytes; + + // 6. Let chunk be the empty String. + StringBuilder chunk; + + // 7. Let chunkLength be 0. + size_t chunk_length = 0; + + // 8. Let index be 0. + size_t index = 0; + + // 9. Let length be the length of string. + auto length = string.length(); + + // 10. Repeat, + while (true) { + // a. Set index to SkipAsciiWhitespace(string, index). + index = skip_ascii_whitespace(string, index); + + // b. If index = length, then + if (index == length) { + // i. If chunkLength > 0, then + if (chunk_length > 0) { + // 1. If lastChunkHandling is "stop-before-partial", then + if (last_chunk_handling == LastChunkHandling::StopBeforePartial) { + // a. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = read, .bytes = move(bytes), .error = {} }; + } + // 2. Else if lastChunkHandling is "loose", then + else if (last_chunk_handling == LastChunkHandling::Loose) { + VERIFY_NOT_REACHED(); + } + // 3. Else, + else { + // a. Assert: lastChunkHandling is "strict". + VERIFY(last_chunk_handling == LastChunkHandling::Strict); + + // b. Let error be a new SyntaxError exception. + auto error = vm.throw_completion("Invalid trailing data"sv); + + // c. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + } + + // ii. Return the Record { [[Read]]: length, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = length, .bytes = move(bytes), .error = {} }; + } + + // c. Let char be the substring of string from index to index + 1. + auto ch = string[index]; + + // d. Set index to index + 1. + ++index; + + // e. If char is "=", then + if (ch == '=') { + // i. If chunkLength < 2, then + if (chunk_length < 2) { + // 1. Let error be a new SyntaxError exception. + auto error = vm.throw_completion("Unexpected padding character"sv); + + // 2. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + + // ii. Set index to SkipAsciiWhitespace(string, index). + index = skip_ascii_whitespace(string, index); + + // iii. If chunkLength = 2, then + if (chunk_length == 2) { + // 1. If index = length, then + if (index == length) { + // a. If lastChunkHandling is "stop-before-partial", then + if (last_chunk_handling == LastChunkHandling::StopBeforePartial) { + // i. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = read, .bytes = move(bytes), .error = {} }; + } + + // b. Let error be a new SyntaxError exception. + auto error = vm.throw_completion("Incomplete number of padding characters"sv); + + // c. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + + // 2. Set char to the substring of string from index to index + 1. + ch = string[index]; + + // 3. If char is "=", then + if (ch == '=') { + // a. Set index to SkipAsciiWhitespace(string, index + 1). + index = skip_ascii_whitespace(string, index + 1); + } + } + + // iv. If index < length, then + if (index < length) { + // 1. Let error be a new SyntaxError exception. + auto error = vm.throw_completion("Unexpected padding character"sv); + + // 2. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + + // v. If lastChunkHandling is "strict", let throwOnExtraBits be true. + // vi. Else, let throwOnExtraBits be false. + auto throw_on_extra_bits = last_chunk_handling == LastChunkHandling::Strict; + + // vii. Let decodeResult be Completion(DecodeBase64Chunk(chunk, throwOnExtraBits)). + auto decode_result = decode_base64_chunk(vm, chunk, throw_on_extra_bits); + + // viii. If decodeResult is an abrupt completion, then + if (decode_result.is_error()) { + // 1. Let error be decodeResult.[[Value]]. + auto error = decode_result.release_error(); + + // 2. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + + // ix. Set bytes to the list-concatenation of bytes and ! decodeResult. + bytes.append(decode_result.release_value()); + + // x. Return the Record { [[Read]]: length, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = length, .bytes = move(bytes), .error = {} }; + } + + // f. If alphabet is "base64url", then + if (alphabet == Alphabet::Base64URL) { + // i. If char is either "+" or "/", then + if (ch == '+' || ch == '/') { + // 1. Let error be a new SyntaxError exception. + auto error = vm.throw_completion(MUST(String::formatted("Invalid character '{}'"sv, ch))); + + // 2. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + // ii. Else if char is "-", then + else if (ch == '-') { + // 1. Set char to "+". + ch = '+'; + } + // iii. Else if char is "_", then + else if (ch == '-') { + // 1. Set char to "/". + ch = '/'; + } + } + + // g. If the sole code unit of char is not an element of the standard base64 alphabet, then + static constexpr auto standard_base64_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"sv; + + if (!standard_base64_alphabet.contains(ch)) { + // i. Let error be a new SyntaxError exception. + auto error = vm.throw_completion(MUST(String::formatted("Invalid character '{}'"sv, ch))); + + // ii. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: error }. + return { .read = read, .bytes = move(bytes), .error = move(error) }; + } + + // h. Let remaining be maxLength - the length of bytes. + auto remaining = *max_length - bytes.size(); + + // i. If remaining = 1 and chunkLength = 2, or if remaining = 2 and chunkLength = 3, then + if ((remaining == 1 && chunk_length == 2) || (remaining == 2 && chunk_length == 3)) { + // i. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = read, .bytes = move(bytes), .error = {} }; + } + + // j. Set chunk to the string-concatenation of chunk and char. + chunk.append(ch); + + // k. Set chunkLength to the length of chunk. + chunk_length = chunk.length(); + + // l. If chunkLength = 4, then + if (chunk_length == 4) { + // i. Set bytes to the list-concatenation of bytes and ! DecodeBase64Chunk(chunk). + bytes.append(MUST(decode_base64_chunk(vm, chunk))); + + // ii. Set chunk to the empty String. + chunk.clear(); + + // iii. Set chunkLength to 0. + chunk_length = 0; + + // iv. Set read to index. + read = index; + + // v. If the length of bytes = maxLength, then + if (bytes.size() == max_length) { + // 1. Return the Record { [[Read]]: read, [[Bytes]]: bytes, [[Error]]: none }. + return { .read = read, .bytes = move(bytes), .error = {} }; + } + } + } +} + } diff --git a/Userland/Libraries/LibJS/Runtime/Uint8Array.h b/Userland/Libraries/LibJS/Runtime/Uint8Array.h index 415e1efe707..5afe989c824 100644 --- a/Userland/Libraries/LibJS/Runtime/Uint8Array.h +++ b/Userland/Libraries/LibJS/Runtime/Uint8Array.h @@ -6,11 +6,24 @@ #pragma once +#include +#include +#include #include #include +#include +#include namespace JS { +class Uint8ArrayConstructorHelpers { +public: + static void initialize(Realm&, Object& constructor); + +private: + JS_DECLARE_NATIVE_FUNCTION(from_base64); +}; + class Uint8ArrayPrototypeHelpers { public: static void initialize(Realm&, Object& prototype); @@ -25,7 +38,20 @@ enum class Alphabet { Base64URL, }; +enum class LastChunkHandling { + Loose, + Strict, + StopBeforePartial, +}; + +struct DecodeResult { + size_t read { 0 }; // [[Read]] + ByteBuffer bytes; // [[Bytes]] + Optional error; // [[Error]] +}; + ThrowCompletionOr> validate_uint8_array(VM&); ThrowCompletionOr get_uint8_array_bytes(VM&, TypedArrayBase const&); +DecodeResult from_base64(VM&, StringView string, Alphabet alphabet, LastChunkHandling last_chunk_handling, Optional max_length = {}); } diff --git a/Userland/Libraries/LibJS/Tests/builtins/TypedArray/Uint8Array.fromBase64.js b/Userland/Libraries/LibJS/Tests/builtins/TypedArray/Uint8Array.fromBase64.js new file mode 100644 index 00000000000..154fc09f7ba --- /dev/null +++ b/Userland/Libraries/LibJS/Tests/builtins/TypedArray/Uint8Array.fromBase64.js @@ -0,0 +1,120 @@ +describe("errors", () => { + test("invalid string", () => { + expect(() => { + Uint8Array.fromBase64(3.14); + }).toThrowWithMessage(TypeError, "3.14 is not a string"); + }); + + test("invalid options object", () => { + expect(() => { + Uint8Array.fromBase64("", 3.14); + }).toThrowWithMessage(TypeError, "Options is not an object"); + }); + + test("invalid alphabet option", () => { + expect(() => { + Uint8Array.fromBase64("", { alphabet: 3.14 }); + }).toThrowWithMessage(TypeError, "3.14 is not a valid value for option alphabet"); + + expect(() => { + Uint8Array.fromBase64("", { alphabet: "foo" }); + }).toThrowWithMessage(TypeError, "foo is not a valid value for option alphabet"); + }); + + test("invalid lastChunkHandling option", () => { + expect(() => { + Uint8Array.fromBase64("", { lastChunkHandling: 3.14 }); + }).toThrowWithMessage(TypeError, "3.14 is not a valid value for option lastChunkHandling"); + + expect(() => { + Uint8Array.fromBase64("", { lastChunkHandling: "foo" }); + }).toThrowWithMessage(TypeError, "foo is not a valid value for option lastChunkHandling"); + }); + + test("strict mode with trailing data", () => { + expect(() => { + Uint8Array.fromBase64("Zm9va", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Invalid trailing data"); + }); + + test("invalid padding", () => { + expect(() => { + Uint8Array.fromBase64("Zm9v=", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Unexpected padding character"); + + expect(() => { + Uint8Array.fromBase64("Zm9vaa=", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Incomplete number of padding characters"); + + expect(() => { + Uint8Array.fromBase64("Zm9vaa=a", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Unexpected padding character"); + }); + + test("invalid alphabet", () => { + expect(() => { + Uint8Array.fromBase64("-", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Invalid character '-'"); + + expect(() => { + Uint8Array.fromBase64("+", { alphabet: "base64url", lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Invalid character '+'"); + }); + + test("overlong chunk", () => { + expect(() => { + Uint8Array.fromBase64("Zh==", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Extra bits found at end of chunk"); + + expect(() => { + Uint8Array.fromBase64("Zm9=", { lastChunkHandling: "strict" }); + }).toThrowWithMessage(SyntaxError, "Extra bits found at end of chunk"); + }); +}); + +describe("correct behavior", () => { + test("length is 1", () => { + expect(Uint8Array.fromBase64).toHaveLength(1); + }); + + const decodeEqual = (input, expected, options) => { + const decoded = Uint8Array.fromBase64(input, options); + expect(decoded).toEqual(toUTF8Bytes(expected)); + }; + + test("basic functionality", () => { + decodeEqual("", ""); + decodeEqual("Zg==", "f"); + decodeEqual("Zm8=", "fo"); + decodeEqual("Zm9v", "foo"); + decodeEqual("Zm9vYg==", "foob"); + decodeEqual("Zm9vYmE=", "fooba"); + decodeEqual("Zm9vYmFy", "foobar"); + + decodeEqual("8J+kkw==", "🤓"); + decodeEqual("8J+kk2Zvb/CflpY=", "🤓foo🖖"); + }); + + test("base64url alphabet", () => { + const options = { alphabet: "base64url" }; + + decodeEqual("", "", options); + decodeEqual("Zg==", "f", options); + decodeEqual("Zm8=", "fo", options); + decodeEqual("Zm9v", "foo", options); + decodeEqual("Zm9vYg==", "foob", options); + decodeEqual("Zm9vYmE=", "fooba", options); + decodeEqual("Zm9vYmFy", "foobar", options); + + decodeEqual("8J-kkw==", "🤓", options); + decodeEqual("8J-kk2Zvb_CflpY=", "🤓foo🖖", options); + }); + + test("stop-before-partial lastChunkHandling", () => { + const options = { lastChunkHandling: "stop-before-partial" }; + + decodeEqual("Zm9v", "foo", options); + decodeEqual("Zm9va", "foo", options); + decodeEqual("Zm9vaa", "foo", options); + }); +});