Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/jsc/JSValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,13 @@ impl JSValue {
pub fn is_iterable(self, global: &JSGlobalObject) -> JsResult<bool> {
host_fn::from_js_host_call_generic(global, || JSC__JSValue__isIterable(self, global))
}
/// True when the first iterator step reports done. Closes an iterator that has more.
pub fn is_iterable_empty(self, global: &JSGlobalObject) -> JsResult<bool> {
unsafe extern "C" {
safe fn JSC__JSValue__isIterableEmpty(this: JSValue, global: &JSGlobalObject) -> bool;
}
host_fn::from_js_host_call_generic(global, || JSC__JSValue__isIterableEmpty(self, global))
}
/// `JSValue.forEach` — invoke `callback` for each iterable element.
pub fn for_each(
self,
Expand Down
24 changes: 24 additions & 0 deletions src/jsc/bindings/IteratorOperationsBinding.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#include "root.h"

#include <JavaScriptCore/IteratorOperations.h>

using namespace JSC;

// True when the first iterator step reports done. Closes an iterator that has more, like `break` in a for-of.
extern "C" bool JSC__JSValue__isIterableEmpty(EncodedJSValue encodedIterable, JSGlobalObject* globalObject)
{
auto& vm = getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

IterationRecord iterationRecord = iteratorForIterable(globalObject, JSValue::decode(encodedIterable));
RETURN_IF_EXCEPTION(scope, false);

JSValue next = iteratorStep(globalObject, iterationRecord);
RETURN_IF_EXCEPTION(scope, false);
if (next.isFalse())
return true;

scope.release();
iteratorClose(globalObject, iterationRecord.iterator);
return false;
}
23 changes: 2 additions & 21 deletions src/runtime/test_runner/expect/toBeEmpty.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
use core::ffi::c_void;

use bun_jsc::{CallFrame, JSGlobalObject, JSPropertyIterator, JSPropertyIteratorOptions, JSValue, JsResult, VM};
use bun_jsc::{CallFrame, JSGlobalObject, JSPropertyIterator, JSPropertyIteratorOptions, JSValue, JsResult};

use super::{throw, Expect};

Expand All @@ -21,24 +19,7 @@ pub(crate) fn to_be_empty(
if actual_length == f64::INFINITY {
if value.js_type_loose().is_object() {
if value.is_iterable(global)? {
let mut any_properties_in_iterator = false;

extern "C" fn anything_in_iterator(
_: *mut VM,
_: &JSGlobalObject,
any_: *mut c_void,
_: JSValue,
) {
// SAFETY: `any_` is the `&mut bool` passed to `for_each` below.
unsafe { *any_.cast::<bool>() = true };
}

value.for_each(
global,
(&raw mut any_properties_in_iterator).cast::<c_void>(),
anything_in_iterator,
)?;
pass = !any_properties_in_iterator;
pass = value.is_iterable_empty(global)?;
} else {
let Some(_cell) = value.to_cell() else {
return Err(global.throw_type_error(format_args!(
Expand Down
82 changes: 82 additions & 0 deletions test/js/bun/test/expect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3811,6 +3811,88 @@ describe("expect()", () => {
}
});

// jest-extended takes the same one step. Bun also closes an iterator that has more, like `break` in a for-of.
// The iterators are finite and count their steps, so a full walk fails the count and does not hang the runner.
describe("toBeEmpty() takes one step of an iterable", () => {
const counted = (/** @type {number} */ length) => {
const seen = { steps: 0, closed: 0 };
const iterable = {
[Symbol.iterator]: () => ({
next: () => ({ done: ++seen.steps > length, value: seen.steps }),
return: () => (seen.closed++, {}),
}),
};
return { seen, iterable };
};

test("an iterable with elements", () => {
const { seen, iterable } = counted(10);
expect(iterable).not.toBeEmpty();
expect(seen).toEqual({ steps: 1, closed: isBun ? 1 : 0 });
});

test("an iterable with no elements", () => {
const { seen, iterable } = counted(0);
expect(iterable).toBeEmpty();
expect(seen).toEqual({ steps: 1, closed: 0 });
});

test("a failed assertion", () => {
const { seen, iterable } = counted(10);
expect(() => expect(iterable).toBeEmpty()).toThrow("Expected value to be empty");
expect(seen).toEqual({ steps: 1, closed: isBun ? 1 : 0 });
});

test("a Map iterator keeps the entries after the first", () => {
const entries = new Map([
["a", 1],
["b", 2],
["c", 3],
]).entries();
expect(entries).not.toBeEmpty();
expect([...entries]).toEqual([
["b", 2],
["c", 3],
]);
});

if (isBun) {
test("a generator runs its finally block", () => {
const seen = { steps: 0, cleanedUp: false };
const generator = (function* () {
try {
while (++seen.steps <= 10) yield seen.steps;
} finally {
seen.cleanedUp = true;
}
})();
expect(generator).not.toBeEmpty();
expect(seen).toEqual({ steps: 1, cleanedUp: true });
});

test("an error from next() or return() reaches the caller", () => {
const throwsFromNext = {
[Symbol.iterator]: () => ({
next() {
throw new Error("from next");
},
}),
};
let steps = 0;
const throwsFromReturn = {
[Symbol.iterator]: () => ({
next: () => ({ done: ++steps > 10, value: steps }),
return() {
throw new Error("from return");
},
}),
};
expect(() => expect(throwsFromNext).not.toBeEmpty()).toThrow("from next");
expect(() => expect(throwsFromReturn).not.toBeEmpty()).toThrow("from return");
});
}
});

test("toBeEmptyObject()", () => {
// Map and Set are not considered as object in jest-extended
// https://github.com/jestjs/jest/blob/main/packages/jest-get-type/src/index.ts#L26
Expand Down
Loading