diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index e9852689e0a6..0096eb689e01 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2432,6 +2432,13 @@ impl JSValue { pub fn is_iterable(self, global: &JSGlobalObject) -> JsResult { 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 { + 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, diff --git a/src/jsc/bindings/IteratorOperationsBinding.cpp b/src/jsc/bindings/IteratorOperationsBinding.cpp new file mode 100644 index 000000000000..d97be3933f75 --- /dev/null +++ b/src/jsc/bindings/IteratorOperationsBinding.cpp @@ -0,0 +1,24 @@ +#include "root.h" + +#include + +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; +} diff --git a/src/runtime/test_runner/expect/toBeEmpty.rs b/src/runtime/test_runner/expect/toBeEmpty.rs index a1067d3ad76c..c1df9f058537 100644 --- a/src/runtime/test_runner/expect/toBeEmpty.rs +++ b/src/runtime/test_runner/expect/toBeEmpty.rs @@ -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}; @@ -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::() = true }; - } - - value.for_each( - global, - (&raw mut any_properties_in_iterator).cast::(), - 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!( diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index fbeb3d6c49cb..5f87740cb433 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -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