From 6f4b9504e5ee4c0d36aee13bdac7f1438ef866f3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:18:46 -0500 Subject: [PATCH] feat(codemode): support custom async iterators (#38141) --- packages/codemode/interpreter-support.md | 11 +- packages/codemode/src/interpreter/model.ts | 8 +- .../codemode/src/interpreter/references.ts | 5 +- packages/codemode/src/interpreter/runtime.ts | 236 +++++++++-- packages/codemode/src/stdlib/object.ts | 16 +- .../codemode/test/for-await-test262.test.ts | 399 +++++++++++++++++- packages/codemode/test/parity.test.ts | 12 + 7 files changed, 636 insertions(+), 51 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index a9d2025bf5f..467f6e0f5b7 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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 diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index cc66f3b8a92..ef66d8876cb 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -36,7 +36,7 @@ export type StatementResult = export type MemberReference = { target: SafeObject | Array | 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 { diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index f081622f077..ae3a9afa46e 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -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 { @@ -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" diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index d290e817b53..765b97ee5ba 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -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 { private scopes: ScopeStack private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -241,6 +251,7 @@ export class Interpreter { 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 { 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 { 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 { ), ) + 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 { ) } + private awaitValue(value: unknown): Effect.Effect { + 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 { + 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 | undefined { if (value instanceof ToolReference) { return [...this.toolKeys(value.path)] @@ -922,7 +1050,7 @@ export class Interpreter { ) } - const consumed = new Set() + const consumed = new Set() for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") @@ -931,6 +1059,10 @@ export class Interpreter { 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 { 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, key), @@ -1002,7 +1134,7 @@ export class Interpreter { } const source = value as SafeObject | Array - const consumed = new Set() + const consumed = new Set() for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") if (property.type === "RestElement") { @@ -1010,6 +1142,10 @@ export class Interpreter { 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 { 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 { }) } - private destructuringPropertyKey(property: AstNode): Effect.Effect { + private destructuringPropertyKey(property: AstNode): Effect.Effect { if (property.type !== "Property" || getString(property, "kind") !== "init") { throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) } @@ -1055,12 +1191,12 @@ export class Interpreter { return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)) } - private destructuringPropertyValue(source: SafeObject | Array, key: string | number): unknown { - if (!Array.isArray(source)) return source[String(key)] + private destructuringPropertyValue(source: SafeObject | Array, 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 & Array)[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 { 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 { 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 { 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 { ) } + 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 { 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 { 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 & Array)[key]) @@ -2103,13 +2254,13 @@ export class Interpreter { 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(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 { 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)[key] + return Reflect.get(reference.target.url, key) } if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex - return (reference.target as Record)[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 { 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, + ) } } diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 7716ba81156..b4182904d61 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -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, 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, 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 } diff --git a/packages/codemode/test/for-await-test262.test.ts b/packages/codemode/test/for-await-test262.test.ts index 15ab876da58..6b2e682794e 100644 --- a/packages/codemode/test/for-await-test262.test.ts +++ b/packages/codemode/test/for-await-test262.test.ts @@ -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") }) }) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index a801f9b7201..1ea2917d0ad 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -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")