mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Fix crash in NapiClass_ConstructorFunction due to incorrect handling of newTarget (#20552)
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
@@ -34,8 +34,10 @@ JSC_HOST_CALL_ATTRIBUTES JSC::EncodedJSValue NapiClass_ConstructorFunction(JSC::
|
||||
JSValue newTarget;
|
||||
|
||||
if constexpr (ConstructCall) {
|
||||
NapiPrototype* prototype = JSC::jsDynamicCast<NapiPrototype*>(napi->getIfPropertyExists(globalObject, vm.propertyNames->prototype));
|
||||
// Use ::get instead of ::getIfPropertyExists here so that DontEnum is ignored.
|
||||
auto prototypeValue = napi->get(globalObject, vm.propertyNames->prototype);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
NapiPrototype* prototype = JSC::jsDynamicCast<NapiPrototype*>(prototypeValue);
|
||||
|
||||
if (!prototype) {
|
||||
JSC::throwVMError(globalObject, scope, JSC::createTypeError(globalObject, "NapiClass constructor is missing the prototype"_s));
|
||||
@@ -43,9 +45,21 @@ JSC_HOST_CALL_ATTRIBUTES JSC::EncodedJSValue NapiClass_ConstructorFunction(JSC::
|
||||
}
|
||||
|
||||
newTarget = callFrame->newTarget();
|
||||
auto* subclass = prototype->subclass(globalObject, asObject(newTarget));
|
||||
JSObject* thisValue;
|
||||
// Match the behavior from
|
||||
// https://github.com/oven-sh/WebKit/blob/397dafc9721b8f8046f9448abb6dbc14efe096d3/Source/JavaScriptCore/runtime/ObjectConstructor.cpp#L118-L145
|
||||
if (newTarget && newTarget != napi) {
|
||||
JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, asObject(newTarget));
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
Structure* baseStructure = functionGlobalObject->objectStructureForObjectConstructor();
|
||||
Structure* objectStructure = InternalFunction::createSubclassStructure(globalObject, asObject(newTarget), baseStructure);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
thisValue = constructEmptyObject(vm, objectStructure);
|
||||
} else {
|
||||
thisValue = prototype->subclass(globalObject, asObject(newTarget));
|
||||
}
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
callFrame->setThisValue(subclass);
|
||||
callFrame->setThisValue(thisValue);
|
||||
}
|
||||
|
||||
NAPICallFrame frame(globalObject, callFrame, napi->dataPtr(), newTarget);
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <wtf/text/StringView.h>
|
||||
#include <wtf/text/StringBuilder.h>
|
||||
#include <wtf/text/WTFString.h>
|
||||
#include <span>
|
||||
#include "BufferEncodingType.h"
|
||||
#include <JavaScriptCore/AggregateError.h>
|
||||
#include <JavaScriptCore/BytecodeIndex.h>
|
||||
@@ -2331,9 +2332,10 @@ extern "C" napi_status napi_get_value_bigint_int64(napi_env env, napi_value valu
|
||||
*result = jsValue.toBigInt64(toJS(env));
|
||||
|
||||
JSBigInt* bigint = jsValue.asHeapBigInt();
|
||||
uint64_t digit = bigint->length() > 0 ? bigint->digit(0) : 0;
|
||||
auto length = bigint->length();
|
||||
uint64_t digit = length > 0 ? bigint->digit(0) : 0;
|
||||
|
||||
if (bigint->length() > 1) {
|
||||
if (length > 1) {
|
||||
*lossless = false;
|
||||
} else if (bigint->sign()) {
|
||||
// negative
|
||||
@@ -2389,21 +2391,16 @@ extern "C" napi_status napi_get_value_bigint_words(napi_env env,
|
||||
|
||||
JSC::JSBigInt* bigInt = jsValue.asHeapBigInt();
|
||||
|
||||
size_t available_words = *word_count;
|
||||
*word_count = bigInt->length();
|
||||
|
||||
// Return ok in this case
|
||||
if (sign_bit == nullptr && words == nullptr) {
|
||||
*word_count = bigInt->length();
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
*sign_bit = (int)bigInt->sign();
|
||||
|
||||
size_t len = *word_count;
|
||||
for (size_t i = 0; i < available_words && i < len; i++) {
|
||||
words[i] = bigInt->digit(i);
|
||||
}
|
||||
|
||||
std::span<uint64_t> writable_words(words, *word_count);
|
||||
*sign_bit = static_cast<int>(bigInt->sign());
|
||||
*word_count = bigInt->toWordsArray(writable_words);
|
||||
ensureStillAliveHere(bigInt);
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
@@ -2478,6 +2475,30 @@ extern "C" napi_status napi_set_instance_data(napi_env env,
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
extern "C" napi_status napi_create_bigint_uint64(napi_env env, uint64_t value, napi_value* result)
|
||||
{
|
||||
NAPI_PREAMBLE(env);
|
||||
NAPI_CHECK_ARG(env, result);
|
||||
auto* globalObject = toJS(env);
|
||||
auto* bigint = JSBigInt::createFrom(globalObject, value);
|
||||
NAPI_RETURN_IF_EXCEPTION(env);
|
||||
*result = toNapi(bigint, globalObject);
|
||||
ensureStillAliveHere(bigint);
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
extern "C" napi_status napi_create_bigint_int64(napi_env env, int64_t value, napi_value* result)
|
||||
{
|
||||
NAPI_PREAMBLE(env);
|
||||
NAPI_CHECK_ARG(env, result);
|
||||
auto* globalObject = toJS(env);
|
||||
auto* bigint = JSBigInt::createFrom(globalObject, value);
|
||||
NAPI_RETURN_IF_EXCEPTION(env);
|
||||
*result = toNapi(bigint, globalObject);
|
||||
ensureStillAliveHere(bigint);
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
extern "C" napi_status napi_create_bigint_words(napi_env env,
|
||||
int sign_bit,
|
||||
size_t word_count,
|
||||
@@ -2504,34 +2525,16 @@ extern "C" napi_status napi_create_bigint_words(napi_env env,
|
||||
RETURN_IF_EXCEPTION(scope, napi_set_last_error(env, napi_pending_exception));
|
||||
}
|
||||
|
||||
// JSBigInt requires there are no leading zeroes in the words array, but native modules may have
|
||||
// passed an array containing leading zeroes. so we have to cut those off.
|
||||
while (word_count > 0 && words[word_count - 1] == 0) {
|
||||
word_count--;
|
||||
}
|
||||
|
||||
if (word_count == 0) {
|
||||
auto* bigint = JSBigInt::createZero(globalObject);
|
||||
RETURN_IF_EXCEPTION(scope, napi_set_last_error(env, napi_pending_exception));
|
||||
*result = toNapi(bigint, globalObject);
|
||||
return napi_set_last_error(env, napi_ok);
|
||||
}
|
||||
std::span<const uint64_t> words_span(words, word_count);
|
||||
|
||||
// throws RangeError if size is larger than JSC's limit
|
||||
auto* bigint = JSBigInt::createWithLength(globalObject, word_count);
|
||||
auto* bigint = JSBigInt::createFromWords(globalObject, words_span, sign_bit != 0);
|
||||
RETURN_IF_EXCEPTION(scope, napi_set_last_error(env, napi_pending_exception));
|
||||
ASSERT(bigint);
|
||||
|
||||
bigint->setSign(sign_bit != 0);
|
||||
|
||||
const uint64_t* current_word = words;
|
||||
// TODO: add fast path that uses memcpy here instead of setDigit
|
||||
// we need to add this to JSC. V8 has this optimization
|
||||
for (size_t i = 0; i < word_count; i++) {
|
||||
bigint->setDigit(i, *current_word++);
|
||||
}
|
||||
|
||||
*result = toNapi(bigint, globalObject);
|
||||
|
||||
ensureStillAliveHere(bigint);
|
||||
return napi_set_last_error(env, napi_ok);
|
||||
}
|
||||
|
||||
@@ -2560,7 +2563,9 @@ extern "C" napi_status napi_create_symbol(napi_env env, napi_value description,
|
||||
// TODO handle empty string?
|
||||
}
|
||||
|
||||
*result = toNapi(JSC::Symbol::create(vm), globalObject);
|
||||
auto* symbol = JSC::Symbol::create(vm);
|
||||
*result = toNapi(symbol, globalObject);
|
||||
ensureStillAliveHere(symbol);
|
||||
NAPI_RETURN_SUCCESS(env);
|
||||
}
|
||||
|
||||
@@ -2671,8 +2676,9 @@ extern "C" napi_status napi_type_tag_object(napi_env env, napi_value value, cons
|
||||
Zig::GlobalObject* globalObject = toJS(env);
|
||||
JSObject* js_object = toJS(value).getObject();
|
||||
NAPI_RETURN_EARLY_IF_FALSE(env, js_object, napi_object_expected);
|
||||
JSValue napiTypeTagValue = globalObject->napiTypeTags()->get(js_object);
|
||||
|
||||
auto* existing_tag = jsDynamicCast<Bun::NapiTypeTag*>(globalObject->napiTypeTags()->get(js_object));
|
||||
auto* existing_tag = jsDynamicCast<Bun::NapiTypeTag*>(napiTypeTagValue);
|
||||
// cannot tag an object that is already tagged
|
||||
NAPI_RETURN_EARLY_IF_FALSE(env, existing_tag == nullptr, napi_invalid_arg);
|
||||
|
||||
|
||||
@@ -569,8 +569,9 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject)
|
||||
ASSERT(pair->canGetIndexQuickly(1u));
|
||||
workerData = pair->getIndexQuickly(0);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
auto environmentDataValue = pair->getIndexQuickly(1);
|
||||
// it might not be a Map if the parent had not set up environmentData yet
|
||||
environmentData = jsDynamicCast<JSMap*>(pair->getIndexQuickly(1));
|
||||
environmentData = environmentDataValue ? jsDynamicCast<JSMap*>(environmentDataValue) : nullptr;
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
// Main thread starts at 1
|
||||
|
||||
@@ -997,30 +997,8 @@ pub export fn napi_is_date(env_: napi_env, value_: napi_value, is_date_: ?*bool)
|
||||
}
|
||||
pub extern fn napi_get_date_value(env: napi_env, value: napi_value, result: *f64) napi_status;
|
||||
pub extern fn napi_add_finalizer(env: napi_env, js_object: napi_value, native_object: ?*anyopaque, finalize_cb: napi_finalize, finalize_hint: ?*anyopaque, result: napi_ref) napi_status;
|
||||
pub export fn napi_create_bigint_int64(env_: napi_env, value: i64, result_: ?*napi_value) napi_status {
|
||||
log("napi_create_bigint_int64", .{});
|
||||
const env = env_ orelse {
|
||||
return envIsNull();
|
||||
};
|
||||
env.checkGC();
|
||||
const result = result_ orelse {
|
||||
return env.invalidArg();
|
||||
};
|
||||
result.set(env, JSC.JSValue.fromInt64NoTruncate(env.toJS(), value));
|
||||
return env.ok();
|
||||
}
|
||||
pub export fn napi_create_bigint_uint64(env_: napi_env, value: u64, result_: ?*napi_value) napi_status {
|
||||
log("napi_create_bigint_uint64", .{});
|
||||
const env = env_ orelse {
|
||||
return envIsNull();
|
||||
};
|
||||
env.checkGC();
|
||||
const result = result_ orelse {
|
||||
return env.invalidArg();
|
||||
};
|
||||
result.set(env, JSC.JSValue.fromUInt64NoTruncate(env.toJS(), value));
|
||||
return env.ok();
|
||||
}
|
||||
pub extern fn napi_create_bigint_int64(env: napi_env, value: i64, result_: ?*napi_value) napi_status;
|
||||
pub extern fn napi_create_bigint_uint64(env: napi_env, value: u64, result_: ?*napi_value) napi_status;
|
||||
pub extern fn napi_create_bigint_words(env: napi_env, sign_bit: c_int, word_count: usize, words: [*c]const u64, result: *napi_value) napi_status;
|
||||
pub extern fn napi_get_value_bigint_int64(_: napi_env, value_: napi_value, result_: ?*i64, _: *bool) napi_status;
|
||||
pub extern fn napi_get_value_bigint_uint64(_: napi_env, value_: napi_value, result_: ?*u64, _: *bool) napi_status;
|
||||
|
||||
@@ -164,8 +164,53 @@ static napi_value get_class_with_constructor(const Napi::CallbackInfo &info) {
|
||||
return napi_class;
|
||||
}
|
||||
|
||||
static napi_value test_constructor_with_no_prototype(const Napi::CallbackInfo &info) {
|
||||
// This test verifies that Reflect.construct with a newTarget that has no prototype
|
||||
// property doesn't crash. This was a bug where jsDynamicCast was called on a JSValue
|
||||
// of 0 when the prototype property didn't exist.
|
||||
|
||||
napi_env env = info.Env();
|
||||
|
||||
// Get the NapiClass constructor
|
||||
napi_value napi_class = get_class_with_constructor(info);
|
||||
|
||||
// Create a newTarget object with no prototype property
|
||||
napi_value new_target;
|
||||
NODE_API_CALL(env, napi_create_object(env, &new_target));
|
||||
|
||||
// Call Reflect.construct(NapiClass, [], newTarget)
|
||||
napi_value global;
|
||||
NODE_API_CALL(env, napi_get_global(env, &global));
|
||||
|
||||
napi_value reflect;
|
||||
NODE_API_CALL(env, napi_get_named_property(env, global, "Reflect", &reflect));
|
||||
|
||||
napi_value construct_fn;
|
||||
NODE_API_CALL(env, napi_get_named_property(env, reflect, "construct", &construct_fn));
|
||||
|
||||
napi_value empty_array;
|
||||
NODE_API_CALL(env, napi_create_array_with_length(env, 0, &empty_array));
|
||||
|
||||
napi_value args[3] = { napi_class, empty_array, new_target };
|
||||
napi_value result;
|
||||
|
||||
// This should not crash - previously it would crash when trying to access
|
||||
// the prototype property of newTarget
|
||||
napi_status status = napi_call_function(env, reflect, construct_fn, 3, args, &result);
|
||||
|
||||
if (status == napi_ok) {
|
||||
return Napi::String::New(env, "success - no crash");
|
||||
} else {
|
||||
// If there was an error, return it
|
||||
const napi_extended_error_info* error_info;
|
||||
napi_get_last_error_info(env, &error_info);
|
||||
return Napi::String::New(env, error_info->error_message ? error_info->error_message : "error");
|
||||
}
|
||||
}
|
||||
|
||||
void register_class_test(Napi::Env env, Napi::Object exports) {
|
||||
REGISTER_FUNCTION(env, exports, get_class_with_constructor);
|
||||
REGISTER_FUNCTION(env, exports, test_constructor_with_no_prototype);
|
||||
}
|
||||
|
||||
} // namespace napitests
|
||||
|
||||
@@ -19,6 +19,10 @@ test_get_value_string_any_encoding(const Napi::CallbackInfo &info) {
|
||||
std::array<Element, BUFSIZE> buf;
|
||||
napi_value string = info[0];
|
||||
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
size_t full_length;
|
||||
NODE_API_CALL(env,
|
||||
get_value_string_fn(env, string, nullptr, 0, &full_length));
|
||||
|
||||
@@ -399,6 +399,51 @@ nativeTests.test_reflect_construct_napi_class = () => {
|
||||
console.log("reflect constructed data =", instance.getData?.());
|
||||
};
|
||||
|
||||
nativeTests.test_reflect_construct_no_prototype_crash = () => {
|
||||
// This test verifies the fix for jsDynamicCast being called on JSValue(0)
|
||||
// when a NAPI class constructor is called via Reflect.construct with a
|
||||
// newTarget that has no prototype property.
|
||||
|
||||
const NapiClass = nativeTests.get_class_with_constructor();
|
||||
|
||||
// Test 1: Constructor function with deleted prototype property
|
||||
// This case should work without crashing
|
||||
function ConstructorWithoutPrototype() {}
|
||||
delete ConstructorWithoutPrototype.prototype;
|
||||
|
||||
try {
|
||||
const instance1 = Reflect.construct(NapiClass, [], ConstructorWithoutPrototype);
|
||||
console.log("constructor without prototype: success - no crash");
|
||||
} catch (e) {
|
||||
console.log("constructor without prototype error:", e.message);
|
||||
}
|
||||
|
||||
// Test 2: Regular constructor (control test)
|
||||
// This should always work
|
||||
function NormalConstructor() {}
|
||||
|
||||
try {
|
||||
const instance2 = Reflect.construct(NapiClass, [], NormalConstructor);
|
||||
console.log("normal constructor: success - no crash");
|
||||
} catch (e) {
|
||||
console.log("normal constructor error:", e.message);
|
||||
}
|
||||
|
||||
// Test 3: Reflect.construct with Proxy newTarget (prototype returns undefined)
|
||||
function ProxyObject() {}
|
||||
|
||||
const proxyTarget = new Proxy(ProxyObject, {
|
||||
get(target, prop) {
|
||||
if (prop === "prototype") {
|
||||
return undefined;
|
||||
}
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
const instance3 = Reflect.construct(NapiClass, [], proxyTarget);
|
||||
console.log("✓ Success - no crash!");
|
||||
};
|
||||
|
||||
nativeTests.test_napi_wrap = () => {
|
||||
const values = [
|
||||
{},
|
||||
|
||||
@@ -109,6 +109,9 @@ test_napi_get_value_string_utf8_with_buffer(const Napi::CallbackInfo &info) {
|
||||
|
||||
NODE_API_CALL(env,
|
||||
napi_get_value_string_utf8(env, string_js, buf, len, &copied));
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
std::cout << "Chars to copy: " << len << std::endl;
|
||||
std::cout << "Copied chars: " << copied << std::endl;
|
||||
@@ -118,6 +121,7 @@ test_napi_get_value_string_utf8_with_buffer(const Napi::CallbackInfo &info) {
|
||||
}
|
||||
std::cout << std::endl;
|
||||
std::cout << "Value str: " << buf << std::endl;
|
||||
|
||||
return ok(env);
|
||||
}
|
||||
|
||||
@@ -163,15 +167,23 @@ test_napi_handle_scope_bigint(const Napi::CallbackInfo &info) {
|
||||
|
||||
auto *small_ints = new napi_value[num_small_ints];
|
||||
|
||||
for (size_t i = 0; i < num_small_ints; i++) {
|
||||
std::array<uint64_t, small_int_size> words;
|
||||
words.fill(i + 1);
|
||||
NODE_API_CALL(env, napi_create_bigint_words(env, 0, small_int_size,
|
||||
words.data(), &small_ints[i]));
|
||||
for (size_t i = 0, small_int_index = 1; i < num_small_ints;
|
||||
i++, small_int_index++) {
|
||||
uint64_t words[small_int_size];
|
||||
for (size_t j = 0; j < small_int_size; j++) {
|
||||
words[j] = small_int_index;
|
||||
}
|
||||
|
||||
NODE_API_CALL(env, napi_create_bigint_words(env, 0, small_int_size, words,
|
||||
&small_ints[i]));
|
||||
}
|
||||
|
||||
run_gc(info);
|
||||
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
for (size_t j = 0; j < num_small_ints; j++) {
|
||||
std::array<uint64_t, small_int_size> words;
|
||||
int sign;
|
||||
@@ -370,7 +382,8 @@ static napi_value test_napi_throw_with_nullptr(const Napi::CallbackInfo &info) {
|
||||
|
||||
bool is_exception_pending;
|
||||
NODE_API_CALL(env, napi_is_exception_pending(env, &is_exception_pending));
|
||||
printf("napi_is_exception_pending -> %s\n", is_exception_pending ? "true" : "false");
|
||||
printf("napi_is_exception_pending -> %s\n",
|
||||
is_exception_pending ? "true" : "false");
|
||||
|
||||
return ok(env);
|
||||
}
|
||||
@@ -382,6 +395,10 @@ static napi_value test_extended_error_messages(const Napi::CallbackInfo &info) {
|
||||
napi_env env = info.Env();
|
||||
const napi_extended_error_info *error;
|
||||
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
// this function is implemented in C++
|
||||
// error because the result pointer is null
|
||||
printf("erroneous napi_create_double returned code %d\n",
|
||||
@@ -432,6 +449,11 @@ static napi_value test_extended_error_messages(const Napi::CallbackInfo &info) {
|
||||
|
||||
static napi_value bigint_to_i64(const Napi::CallbackInfo &info) {
|
||||
napi_env env = info.Env();
|
||||
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
// start at 1 is intentional, since argument 0 is the callback to run GC
|
||||
// passed to every function
|
||||
// perform test on all arguments
|
||||
@@ -460,6 +482,10 @@ static napi_value bigint_to_i64(const Napi::CallbackInfo &info) {
|
||||
|
||||
static napi_value bigint_to_u64(const Napi::CallbackInfo &info) {
|
||||
napi_env env = info.Env();
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
// start at 1 is intentional, since argument 0 is the callback to run GC
|
||||
// passed to every function
|
||||
// perform test on all arguments
|
||||
@@ -489,6 +515,10 @@ static napi_value bigint_to_u64(const Napi::CallbackInfo &info) {
|
||||
static napi_value bigint_to_64_null(const Napi::CallbackInfo &info) {
|
||||
napi_env env = info.Env();
|
||||
|
||||
#ifndef _WIN32
|
||||
BlockingStdoutScope stdout_scope;
|
||||
#endif
|
||||
|
||||
napi_value bigint;
|
||||
NODE_API_CALL(env, napi_create_bigint_int64(env, 5, &bigint));
|
||||
|
||||
|
||||
@@ -2,6 +2,33 @@
|
||||
#include "napi_with_version.h"
|
||||
#include <climits>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Node.js makes stdout non-blocking
|
||||
// This messes up printf when you spam it quickly enough.
|
||||
class BlockingStdoutScope {
|
||||
public:
|
||||
BlockingStdoutScope() {
|
||||
original = fcntl(1, F_GETFL);
|
||||
fcntl(1, F_SETFL, original & ~O_NONBLOCK);
|
||||
setvbuf(stdout, nullptr, _IOFBF, 8192);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
~BlockingStdoutScope() {
|
||||
fflush(stdout);
|
||||
fcntl(1, F_SETFL, original);
|
||||
setvbuf(stdout, nullptr, _IOLBF, 0);
|
||||
}
|
||||
|
||||
private:
|
||||
int original;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
// e.g NODE_API_CALL(env, napi_create_int32(env, 5, &my_napi_integer))
|
||||
#define NODE_API_CALL(env, call) NODE_API_CALL_CUSTOM_RETURN(env, NULL, call)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "bun";
|
||||
import { spawn, spawnSync } from "bun";
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { readdirSync } from "fs";
|
||||
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
|
||||
@@ -150,88 +150,88 @@ describe("napi", () => {
|
||||
});
|
||||
|
||||
describe("issue_7685", () => {
|
||||
it("works", () => {
|
||||
it("works", async () => {
|
||||
const args = [...Array(20).keys()];
|
||||
checkSameOutput("test_issue_7685", args);
|
||||
await checkSameOutput("test_issue_7685", args);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue_11949", () => {
|
||||
it("napi_call_threadsafe_function should accept null", () => {
|
||||
const result = checkSameOutput("test_issue_11949", []);
|
||||
it("napi_call_threadsafe_function should accept null", async () => {
|
||||
const result = await checkSameOutput("test_issue_11949", []);
|
||||
expect(result).toStartWith("data = 1234, context = 42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_get_value_string_utf8 with buffer", () => {
|
||||
// see https://github.com/oven-sh/bun/issues/6949
|
||||
it("copies one char", () => {
|
||||
const result = checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 2]);
|
||||
it("copies one char", async () => {
|
||||
const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 2]);
|
||||
expect(result).toEndWith("str: a");
|
||||
});
|
||||
|
||||
it("copies null terminator", () => {
|
||||
const result = checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 1]);
|
||||
it("copies null terminator", async () => {
|
||||
const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 1]);
|
||||
expect(result).toEndWith("str:");
|
||||
});
|
||||
|
||||
it("copies zero char", () => {
|
||||
const result = checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 0]);
|
||||
it("copies zero char", async () => {
|
||||
const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 0]);
|
||||
expect(result).toEndWith("str: *****************************");
|
||||
});
|
||||
|
||||
it("copies more than given len", () => {
|
||||
const result = checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 25]);
|
||||
it("copies more than given len", async () => {
|
||||
const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 25]);
|
||||
expect(result).toEndWith("str: abcdef");
|
||||
});
|
||||
|
||||
it("copies auto len", () => {
|
||||
const result = checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 424242]);
|
||||
it("copies auto len", async () => {
|
||||
const result = await checkSameOutput("test_napi_get_value_string_utf8_with_buffer", ["abcdef", 424242]);
|
||||
expect(result).toEndWith("str:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_get_value_string_*", () => {
|
||||
it("behaves like node on edge cases", () => {
|
||||
checkSameOutput("test_get_value_string", []);
|
||||
it("behaves like node on edge cases", async () => {
|
||||
await checkSameOutput("test_get_value_string", []);
|
||||
});
|
||||
});
|
||||
|
||||
it("#1288", async () => {
|
||||
const result = checkSameOutput("self", []);
|
||||
const result = await checkSameOutput("self", []);
|
||||
expect(result).toBe("hello world!");
|
||||
});
|
||||
|
||||
describe("handle_scope", () => {
|
||||
it("keeps strings alive", () => {
|
||||
checkSameOutput("test_napi_handle_scope_string", []);
|
||||
it("keeps strings alive", async () => {
|
||||
await checkSameOutput("test_napi_handle_scope_string", []);
|
||||
});
|
||||
it("keeps bigints alive", () => {
|
||||
checkSameOutput("test_napi_handle_scope_bigint", []);
|
||||
it("keeps bigints alive", async () => {
|
||||
await checkSameOutput("test_napi_handle_scope_bigint", []);
|
||||
}, 10000);
|
||||
it("keeps the parent handle scope alive", () => {
|
||||
checkSameOutput("test_napi_handle_scope_nesting", []);
|
||||
it("keeps the parent handle scope alive", async () => {
|
||||
await checkSameOutput("test_napi_handle_scope_nesting", []);
|
||||
});
|
||||
it("exists when calling a napi constructor", () => {
|
||||
checkSameOutput("test_napi_class_constructor_handle_scope", []);
|
||||
it("exists when calling a napi constructor", async () => {
|
||||
await checkSameOutput("test_napi_class_constructor_handle_scope", []);
|
||||
});
|
||||
it("exists while calling a napi_async_complete_callback", () => {
|
||||
checkSameOutput("create_promise", [false]);
|
||||
it("exists while calling a napi_async_complete_callback", async () => {
|
||||
await checkSameOutput("create_promise", [false]);
|
||||
});
|
||||
it("keeps arguments moved off the stack alive", () => {
|
||||
checkSameOutput("test_napi_handle_scope_many_args", ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]);
|
||||
it("keeps arguments moved off the stack alive", async () => {
|
||||
await checkSameOutput("test_napi_handle_scope_many_args", ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapable_handle_scope", () => {
|
||||
it("keeps the escaped value alive in the outer scope", () => {
|
||||
checkSameOutput("test_napi_escapable_handle_scope", []);
|
||||
it("keeps the escaped value alive in the outer scope", async () => {
|
||||
await checkSameOutput("test_napi_escapable_handle_scope", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_delete_property", () => {
|
||||
it("returns a valid boolean", () => {
|
||||
checkSameOutput(
|
||||
it("returns a valid boolean", async () => {
|
||||
await checkSameOutput(
|
||||
"test_napi_delete_property",
|
||||
// generate a string representing an array around an IIFE which main.js will eval
|
||||
// we do this as the napi_delete_property test needs an object with an own non-configurable
|
||||
@@ -253,48 +253,48 @@ describe("napi", () => {
|
||||
});
|
||||
|
||||
describe("napi_ref", () => {
|
||||
it("can recover the value from a weak ref", () => {
|
||||
checkSameOutput("test_napi_ref", []);
|
||||
it("can recover the value from a weak ref", async () => {
|
||||
await checkSameOutput("test_napi_ref", []);
|
||||
});
|
||||
it("allows creating a handle scope in the finalizer", () => {
|
||||
checkSameOutput("test_napi_handle_scope_finalizer", []);
|
||||
it("allows creating a handle scope in the finalizer", async () => {
|
||||
await checkSameOutput("test_napi_handle_scope_finalizer", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_async_work", () => {
|
||||
it("null checks execute callbacks", () => {
|
||||
const output = checkSameOutput("test_napi_async_work_execute_null_check", []);
|
||||
it("null checks execute callbacks", async () => {
|
||||
const output = await checkSameOutput("test_napi_async_work_execute_null_check", []);
|
||||
expect(output).toContain("success!");
|
||||
expect(output).not.toContain("failure!");
|
||||
});
|
||||
it("null checks complete callbacks after scheduling", () => {
|
||||
checkSameOutput("test_napi_async_work_complete_null_check", []);
|
||||
it("null checks complete callbacks after scheduling", async () => {
|
||||
await checkSameOutput("test_napi_async_work_complete_null_check", []);
|
||||
});
|
||||
it("works with cancelation", () => {
|
||||
const output = checkSameOutput("test_napi_async_work_cancel", [], { "UV_THREADPOOL_SIZE": "2" });
|
||||
it("works with cancelation", async () => {
|
||||
const output = await checkSameOutput("test_napi_async_work_cancel", [], { "UV_THREADPOOL_SIZE": "2" });
|
||||
expect(output).toContain("success!");
|
||||
expect(output).not.toContain("failure!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_threadsafe_function", () => {
|
||||
it("keeps the event loop alive without async_work", () => {
|
||||
const result = checkSameOutput("test_promise_with_threadsafe_function", []);
|
||||
it("keeps the event loop alive without async_work", async () => {
|
||||
const result = await checkSameOutput("test_promise_with_threadsafe_function", []);
|
||||
expect(result).toContain("tsfn_callback");
|
||||
expect(result).toContain("resolved to 1234");
|
||||
expect(result).toContain("tsfn_finalize_callback");
|
||||
});
|
||||
|
||||
it("does not hang on finalize", () => {
|
||||
const result = checkSameOutput("test_napi_threadsafe_function_does_not_hang_after_finalize", []);
|
||||
it("does not hang on finalize", async () => {
|
||||
const result = await checkSameOutput("test_napi_threadsafe_function_does_not_hang_after_finalize", []);
|
||||
expect(result).toBe("success!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exception handling", () => {
|
||||
it("can check for a pending error and catch the right value", () => {
|
||||
checkSameOutput("test_get_exception", [5]);
|
||||
checkSameOutput("test_get_exception", [{ foo: "bar" }]);
|
||||
it("can check for a pending error and catch the right value", async () => {
|
||||
await checkSameOutput("test_get_exception", [5]);
|
||||
await checkSameOutput("test_get_exception", [{ foo: "bar" }]);
|
||||
});
|
||||
it("can throw an exception from an async_complete_callback", async () => {
|
||||
const count = 10;
|
||||
@@ -303,19 +303,19 @@ describe("napi", () => {
|
||||
});
|
||||
|
||||
describe("napi_run_script", () => {
|
||||
it("evaluates a basic expression", () => {
|
||||
checkSameOutput("test_napi_run_script", ["5 * (1 + 2)"]);
|
||||
it("evaluates a basic expression", async () => {
|
||||
await checkSameOutput("test_napi_run_script", ["5 * (1 + 2)"]);
|
||||
});
|
||||
it("provides the right this value", () => {
|
||||
checkSameOutput("test_napi_run_script", ["this === global"]);
|
||||
it("provides the right this value", async () => {
|
||||
await checkSameOutput("test_napi_run_script", ["this === global"]);
|
||||
});
|
||||
it("propagates exceptions", () => {
|
||||
checkSameOutput("test_napi_run_script", ["(()=>{ throw new TypeError('oops'); })()"]);
|
||||
it("propagates exceptions", async () => {
|
||||
await checkSameOutput("test_napi_run_script", ["(()=>{ throw new TypeError('oops'); })()"]);
|
||||
});
|
||||
it("cannot see locals from around its invocation", () => {
|
||||
it("cannot see locals from around its invocation", async () => {
|
||||
// variable should_not_exist is declared on main.js:18, but it should not be in scope for the eval'd code
|
||||
// this doesn't use checkSameOutput because V8 and JSC use different error messages for a missing variable
|
||||
let bunResult = runOn(bunExe(), "test_napi_run_script", ["shouldNotExist"]);
|
||||
// this doesn't use await checkSameOutput because V8 and JSC use different error messages for a missing variable
|
||||
let bunResult = await runOn(bunExe(), "test_napi_run_script", ["shouldNotExist"]);
|
||||
// remove all debug logs
|
||||
bunResult = bunResult.replaceAll(/^\[\w+\].+$/gm, "").trim();
|
||||
expect(bunResult).toBe(
|
||||
@@ -325,100 +325,104 @@ describe("napi", () => {
|
||||
});
|
||||
|
||||
describe("napi_get_named_property", () => {
|
||||
it("handles edge cases", () => {
|
||||
checkSameOutput("test_get_property", []);
|
||||
it("handles edge cases", async () => {
|
||||
await checkSameOutput("test_get_property", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_set_named_property", () => {
|
||||
it("handles edge cases", () => {
|
||||
checkSameOutput("test_set_property", []);
|
||||
it("handles edge cases", async () => {
|
||||
await checkSameOutput("test_set_property", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_value <=> integer conversion", () => {
|
||||
it("works", () => {
|
||||
checkSameOutput("test_number_integer_conversions_from_js", []);
|
||||
checkSameOutput("test_number_integer_conversions", []);
|
||||
it("works", async () => {
|
||||
await checkSameOutput("test_number_integer_conversions_from_js", []);
|
||||
await checkSameOutput("test_number_integer_conversions", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrays", () => {
|
||||
describe("napi_create_array_with_length", () => {
|
||||
it("creates an array with empty slots", () => {
|
||||
checkSameOutput("test_create_array_with_length", []);
|
||||
it("creates an array with empty slots", async () => {
|
||||
await checkSameOutput("test_create_array_with_length", []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_throw functions", () => {
|
||||
it("has the right code and message", () => {
|
||||
checkSameOutput("test_throw_functions_exhaustive", []);
|
||||
it("has the right code and message", async () => {
|
||||
await checkSameOutput("test_throw_functions_exhaustive", []);
|
||||
});
|
||||
|
||||
it("does not throw with nullptr", () => {
|
||||
checkSameOutput("test_napi_throw_with_nullptr", []);
|
||||
it("does not throw with nullptr", async () => {
|
||||
await checkSameOutput("test_napi_throw_with_nullptr", []);
|
||||
});
|
||||
});
|
||||
describe("napi_create_error functions", () => {
|
||||
it("has the right code and message", () => {
|
||||
checkSameOutput("test_create_error_functions_exhaustive", []);
|
||||
it("has the right code and message", async () => {
|
||||
await checkSameOutput("test_create_error_functions_exhaustive", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_type_tag_object", () => {
|
||||
it("works", () => {
|
||||
checkSameOutput("test_type_tag", []);
|
||||
it("works", async () => {
|
||||
await checkSameOutput("test_type_tag", []);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO(@190n) test allocating in a finalizer from a napi module with the right version
|
||||
|
||||
describe("napi_wrap", () => {
|
||||
it("accepts the right kinds of values", () => {
|
||||
checkSameOutput("test_napi_wrap", []);
|
||||
it("accepts the right kinds of values", async () => {
|
||||
await checkSameOutput("test_napi_wrap", []);
|
||||
});
|
||||
|
||||
it("is shared between addons", () => {
|
||||
checkSameOutput("test_napi_wrap_cross_addon", []);
|
||||
it("is shared between addons", async () => {
|
||||
await checkSameOutput("test_napi_wrap_cross_addon", []);
|
||||
});
|
||||
|
||||
it("does not follow prototypes", () => {
|
||||
checkSameOutput("test_napi_wrap_prototype", []);
|
||||
it("does not follow prototypes", async () => {
|
||||
await checkSameOutput("test_napi_wrap_prototype", []);
|
||||
});
|
||||
|
||||
it("does not consider proxies", () => {
|
||||
checkSameOutput("test_napi_wrap_proxy", []);
|
||||
it("does not consider proxies", async () => {
|
||||
await checkSameOutput("test_napi_wrap_proxy", []);
|
||||
});
|
||||
|
||||
it("can remove a wrap", () => {
|
||||
checkSameOutput("test_napi_remove_wrap", []);
|
||||
it("can remove a wrap", async () => {
|
||||
await checkSameOutput("test_napi_remove_wrap", []);
|
||||
});
|
||||
|
||||
it("has the right lifetime", () => {
|
||||
checkSameOutput("test_wrap_lifetime_without_ref", []);
|
||||
checkSameOutput("test_wrap_lifetime_with_weak_ref", []);
|
||||
checkSameOutput("test_wrap_lifetime_with_strong_ref", []);
|
||||
checkSameOutput("test_remove_wrap_lifetime_with_weak_ref", []);
|
||||
checkSameOutput("test_remove_wrap_lifetime_with_strong_ref", []);
|
||||
it("has the right lifetime", async () => {
|
||||
await checkSameOutput("test_wrap_lifetime_without_ref", []);
|
||||
await checkSameOutput("test_wrap_lifetime_with_weak_ref", []);
|
||||
await checkSameOutput("test_wrap_lifetime_with_strong_ref", []);
|
||||
await checkSameOutput("test_remove_wrap_lifetime_with_weak_ref", []);
|
||||
await checkSameOutput("test_remove_wrap_lifetime_with_strong_ref", []);
|
||||
// check that napi finalizers also run at VM exit, even if they didn't get run by GC
|
||||
checkSameOutput("test_ref_deleted_in_cleanup", []);
|
||||
await checkSameOutput("test_ref_deleted_in_cleanup", []);
|
||||
// check that calling napi_delete_ref in the ref's finalizer is not use-after-free
|
||||
checkSameOutput("test_ref_deleted_in_async_finalize", []);
|
||||
await checkSameOutput("test_ref_deleted_in_async_finalize", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_define_class", () => {
|
||||
it("handles edge cases in the constructor", () => {
|
||||
checkSameOutput("test_napi_class", []);
|
||||
checkSameOutput("test_subclass_napi_class", []);
|
||||
checkSameOutput("test_napi_class_non_constructor_call", []);
|
||||
checkSameOutput("test_reflect_construct_napi_class", []);
|
||||
it("handles edge cases in the constructor", async () => {
|
||||
await checkSameOutput("test_napi_class", []);
|
||||
await checkSameOutput("test_subclass_napi_class", []);
|
||||
await checkSameOutput("test_napi_class_non_constructor_call", []);
|
||||
await checkSameOutput("test_reflect_construct_napi_class", []);
|
||||
});
|
||||
|
||||
it("does not crash with Reflect.construct when newTarget has no prototype", async () => {
|
||||
await checkSameOutput("test_reflect_construct_no_prototype_crash", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bigint conversion to int64/uint64", () => {
|
||||
it("works", () => {
|
||||
it("works", async () => {
|
||||
const tests = [-1n, 0n, 1n];
|
||||
for (const power of [63, 64, 65]) {
|
||||
for (const sign of [-1, 1]) {
|
||||
@@ -428,26 +432,26 @@ describe("napi", () => {
|
||||
}
|
||||
|
||||
const testsString = "[" + tests.map(bigint => bigint.toString() + "n").join(",") + "]";
|
||||
checkSameOutput("bigint_to_i64", testsString);
|
||||
checkSameOutput("bigint_to_u64", testsString);
|
||||
await checkSameOutput("bigint_to_i64", testsString);
|
||||
await checkSameOutput("bigint_to_u64", testsString);
|
||||
});
|
||||
it("returns the right error code", () => {
|
||||
it("returns the right error code", async () => {
|
||||
const badTypes = '[null, undefined, 5, "123", "abc"]';
|
||||
checkSameOutput("bigint_to_i64", badTypes);
|
||||
checkSameOutput("bigint_to_u64", badTypes);
|
||||
checkSameOutput("bigint_to_64_null", []);
|
||||
await checkSameOutput("bigint_to_i64", badTypes);
|
||||
await checkSameOutput("bigint_to_u64", badTypes);
|
||||
await checkSameOutput("bigint_to_64_null", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_bigint_words", () => {
|
||||
it("works", () => {
|
||||
checkSameOutput("test_create_bigint_words", []);
|
||||
it("works", async () => {
|
||||
await checkSameOutput("test_create_bigint_words", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("napi_get_last_error_info", () => {
|
||||
it("returns information from the most recent call", () => {
|
||||
checkSameOutput("test_extended_error_messages", []);
|
||||
it("returns information from the most recent call", async () => {
|
||||
await checkSameOutput("test_extended_error_messages", []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -463,10 +467,10 @@ describe("napi", () => {
|
||||
["[1, 2, 3]", false],
|
||||
["'hello'", false],
|
||||
];
|
||||
it("returns consistent values with node.js", () => {
|
||||
it("returns consistent values with node.js", async () => {
|
||||
for (const [value, expected] of tests) {
|
||||
// main.js does eval then spread so to pass a single value we need to wrap in an array
|
||||
const output = checkSameOutput(`test_is_${kind}`, "[" + value + "]");
|
||||
const output = await checkSameOutput(`test_is_${kind}`, "[" + value + "]");
|
||||
expect(output).toBe(`napi_is_${kind} -> ${expected.toString()}`);
|
||||
}
|
||||
});
|
||||
@@ -479,26 +483,33 @@ describe("napi", () => {
|
||||
])("works when the module register function returns %s", (returnKind, expected) => {
|
||||
expect(require(`./napi-app/build/Debug/${returnKind}_addon.node`)).toEqual(expected);
|
||||
});
|
||||
it("works when the module register function throws", () => {
|
||||
it("works when the module register function throws", async () => {
|
||||
expect(() => require("./napi-app/build/Debug/throw_addon.node")).toThrow(new Error("oops!"));
|
||||
});
|
||||
});
|
||||
|
||||
function checkSameOutput(test: string, args: any[] | string, envArgs: Record<string, string> = {}) {
|
||||
const nodeResult = runOn("node", test, args, envArgs).trim();
|
||||
let bunResult = runOn(bunExe(), test, args, envArgs);
|
||||
async function checkSameOutput(test: string, args: any[] | string, envArgs: Record<string, string> = {}) {
|
||||
let [nodeResult, bunResult] = await Promise.all([
|
||||
runOn("node", test, args, envArgs),
|
||||
runOn(bunExe(), test, args, envArgs),
|
||||
]);
|
||||
nodeResult = nodeResult.trim();
|
||||
// remove all debug logs
|
||||
bunResult = bunResult.replaceAll(/^\[\w+\].+$/gm, "").trim();
|
||||
bunResult = bunResult
|
||||
.replaceAll(/^\[\w+\].+$/gm, "")
|
||||
// TODO: we don't seem to print ProxyObject in this case.
|
||||
.replaceAll("function ProxyObject()", "function ()")
|
||||
.trim();
|
||||
expect(bunResult).toEqual(nodeResult);
|
||||
return nodeResult;
|
||||
}
|
||||
|
||||
function runOn(executable: string, test: string, args: any[] | string, envArgs: Record<string, string> = {}) {
|
||||
async function runOn(executable: string, test: string, args: any[] | string, envArgs: Record<string, string> = {}) {
|
||||
// when the inspector runs (can be due to VSCode extension), there is
|
||||
// a bug that in debug modes the console logs extra stuff
|
||||
const { BUN_INSPECT_CONNECT_TO: _, ...rest } = bunEnv;
|
||||
const env = { ...rest, ...envArgs };
|
||||
const exec = spawnSync({
|
||||
const exec = spawn({
|
||||
cmd: [
|
||||
executable,
|
||||
"--expose-gc",
|
||||
@@ -507,11 +518,19 @@ function runOn(executable: string, test: string, args: any[] | string, envArgs:
|
||||
typeof args == "string" ? args : JSON.stringify(args),
|
||||
],
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "inherit",
|
||||
});
|
||||
const errs = exec.stderr.toString();
|
||||
const [stdout, stderr, result] = await Promise.all([
|
||||
new Response(exec.stdout).text(),
|
||||
new Response(exec.stderr).text(),
|
||||
exec.exited,
|
||||
]);
|
||||
const errs = stderr.toString();
|
||||
if (errs !== "") {
|
||||
throw new Error(errs);
|
||||
}
|
||||
expect(exec.success).toBeTrue();
|
||||
return exec.stdout.toString();
|
||||
expect(result).toBe(0);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user