feat(codemode): support custom async iterators (#38141)

This commit is contained in:
Aiden Cline 2026-07-21 12:18:46 -05:00 committed by GitHub
parent dbfbb13ccc
commit 6f4b9504e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 636 additions and 51 deletions

View file

@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals.
@ -70,9 +71,11 @@ ultimate source of truth.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`.
- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain
value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain
outside the supported subset.
- [x] `for await...of` over the supported synchronous collections and custom iterator objects using
`Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
## Functions and callbacks

View file

@ -36,7 +36,7 @@ export type StatementResult =
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number
key: PropertyKey
}
export class CodeModeFunction {
@ -61,6 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference {

View file

@ -13,6 +13,7 @@ import {
PromiseMethodReference,
PromiseNamespace,
SearchFunction,
SymbolNamespace,
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> {
@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
)
return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"

View file

@ -1,7 +1,8 @@
import { Cause, Effect } from "effect"
import { Cause, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import {
type AstNode,
AsyncIteratorSymbol,
asNode,
type Binding,
CodeModeFunction,
@ -19,6 +20,8 @@ import {
IntrinsicReference,
InterpreterRuntimeError,
isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference,
type MemberReference,
OptionalShortCircuit,
@ -30,6 +33,7 @@ import {
ProgramThrow,
type ProgramNode,
SearchFunction,
SymbolNamespace,
type StatementResult,
supportedSyntaxMessage,
unsupportedSyntax,
@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
}
}
type CustomIterator = {
iterator: SafeObject
next: unknown
asynchronous: boolean
}
export class Interpreter<R> {
private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@ -241,6 +251,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@ -636,9 +647,10 @@ export class Interpreter<R> {
const body = getNode(node, "body")
const iterable = spreadItems(right)
if (iterable === undefined) {
const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
if (iterable === undefined && iterator === undefined) {
throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
node,
)
}
@ -657,19 +669,14 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
}
for (const value of iterable) {
const resolved = awaiting
? value instanceof CodeModePromise
? yield* self.settlePromise(value)
: yield* Effect.as(Effect.yieldNow, value)
: value
const result = yield* Effect.gen(function* () {
const evaluateBody = (value: unknown) =>
Effect.gen(function* () {
if (declared) {
self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical)
yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) {
yield* self.assignPattern(assignment, resolved, left)
yield* self.assignPattern(assignment, value, left)
}
return yield* self.evaluateStatement(body)
}).pipe(
@ -680,22 +687,48 @@ export class Interpreter<R> {
),
)
if (iterable !== undefined) {
for (const value of iterable) {
const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
if (result.kind === "return") return result
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
}
return { kind: "none" } satisfies StatementResult
}
if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
while (true) {
const step = yield* self.nextIteratorResult(iterator, node)
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") {
yield* self.closeIterator(iterator, node)
return result
}
if (result.kind === "break") {
yield* self.closeIterator(iterator, node)
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue") {
if (result.label !== undefined && !labels?.has(result.label)) return result
continue
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
yield* self.closeIterator(iterator, node)
return result
}
}
return { kind: "none" } satisfies StatementResult
}).pipe(
Effect.ensuring(
Effect.sync(() => {
@ -705,6 +738,101 @@ export class Interpreter<R> {
)
}
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private customIterator(value: unknown, node: AstNode) {
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
(iterator) => {
const object = self.requireIteratorObject(iterator, "Iterator method result", node)
return {
iterator: object,
next: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
})
}
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
const close = iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)]
@ -922,7 +1050,7 @@ export class Interpreter<R> {
)
}
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@ -931,6 +1059,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
Reflect.set(rest, symbol, Reflect.get(value, symbol))
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue
}
@ -939,7 +1071,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@ -1002,7 +1134,7 @@ export class Interpreter<R> {
}
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") {
@ -1010,6 +1142,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
}
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(rest, symbol, Reflect.get(source, symbol))
}
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
@ -1017,7 +1153,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(String(key))
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
@ -1044,7 +1180,7 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
@ -1055,12 +1191,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
@ -1690,6 +1826,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
}
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0])
return undefined
@ -1800,6 +1942,9 @@ export class Interpreter<R> {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
}
continue
}
@ -1830,7 +1975,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
}
return objectValue
@ -1955,6 +2100,12 @@ export class Interpreter<R> {
)
}
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
@ -1976,7 +2127,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
@ -2072,7 +2223,7 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key)
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@ -2103,13 +2254,13 @@ export class Interpreter<R> {
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key]
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
return Reflect.get(reference.target.url, reference.key)
}
return reference.target[String(reference.key)]
return Reflect.get(reference.target, reference.key)
})
}
@ -2171,22 +2322,22 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
}
}
const key = Array.isArray(reference.target) ? reference.key : String(reference.key)
const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
return result
})
}
private readReferenceValue(reference: MemberReference, key: number | string): unknown {
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key]
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key]
return Reflect.get(reference.target, key)
}
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@ -2219,16 +2370,19 @@ export class Interpreter<R> {
return
}
const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next
Reflect.set(target, key, next)
}
private toPropertyKey(value: unknown, node: AstNode): string | number {
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") {
return value
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
}
}

View file

@ -1,4 +1,10 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@ -46,7 +52,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1]))
return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
}
return out
}

View file

@ -1,10 +1,15 @@
/*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
*
* Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262.
*/
import { describe, expect, test } from "bun:test"
@ -130,10 +135,400 @@ describe("Test262 for-await-of adaptations", () => {
).toEqual([1, 3])
})
test("keeps custom iterator objects outside the supported subset", async () => {
test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams")
expect(result.error.message).toContain("or custom iterator value")
})
})

View file

@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")