diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 8fd1bc5af9..8763a2f121 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -64,10 +64,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination. ### Tool execution Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls, -async functions, chained `.then`/`.catch`/`.finally` reactions, `Promise.all`, `Promise.allSettled`, `Promise.race`, -`Promise.resolve`, and `Promise.reject`. Nested functions therefore cannot end the lifetime of work they started. +async functions, chained `.then`/`.catch`/`.finally` reactions, `new Promise(executor)` constructions, and the +`Promise.all`/`allSettled`/`race`/`any`/`resolve`/`reject` statics. Nested functions therefore cannot end the lifetime +of work they started. Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler. -`Promise.race` uses native non-cancelling settlement semantics: its first result wins while losers continue running. +`Promise.race` and `Promise.any` use native non-cancelling settlement semantics: the deciding member wins while losers +continue running, and an all-rejected `Promise.any` rejects with an `AggregateError`. `new Promise(...)` hands the +executor first-class resolve/reject callables that may escape and settle the promise later, exactly once. Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count parity beyond that. At normal completion CodeMode interrupts everything still running - race losers, diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 40d0ab6eee..da4f874e65 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -94,7 +94,7 @@ ultimate source of truth. - [x] Sequence expressions (the comma operator). - [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its continuation one reaction turn. -- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams. +- [x] `new` for Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. - [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`. - [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`. - [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`. @@ -103,15 +103,15 @@ ultimate source of truth. - [x] Prefix and postfix `++` and `--`. - [x] Plain, arithmetic, bitwise, and logical assignment operators. - [ ] Unary `void` and `delete`. -- [ ] Arbitrary constructors and `new Promise(...)`. +- [ ] Arbitrary constructors. ## Promises and tools - [x] Tool calls start eagerly and return supervised, run-once sandbox promises. - [x] Direct `await`, repeated awaits, and implicit resolution when a promise is returned from a function/program. - [x] `Promise.resolve` and `Promise.reject`. -- [x] `Promise.all`, `Promise.allSettled`, and `Promise.race` over supported collections containing promises and plain - values. +- [x] `Promise.all`, `Promise.allSettled`, `Promise.race`, and `Promise.any` over supported collections containing + promises and plain values. - [x] `Promise.all` preserves result order and rejects on the first observed failure without cancelling siblings. - [x] `Promise.allSettled` returns plain fulfilled/rejected outcome records. - [x] `Promise.race` settles from the first result without cancelling losers at settlement time. @@ -130,9 +130,14 @@ ultimate source of truth. diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted without a warning. - [x] `try`/`catch` can handle awaited tool and promise failures. -- [ ] `Promise.any`. +- [x] `Promise.any`: first fulfillment wins; all-rejected rejects with an `AggregateError` whose `errors` array holds + the catch-normalized reasons in input order, and empty input rejects with an empty `AggregateError`. +- [x] `new Promise((resolve, reject) => ...)`: the executor runs synchronously and receives first-class resolve/reject + callables that settle the promise exactly once (they may escape the executor and settle later); an executor + throw rejects unless the promise already settled, resolving with a promise adopts it, and resolving with the + promise itself rejects with a `TypeError`. Resolver callables work as `.then`/`.catch` handlers and collection + callbacks but remain opaque references that cannot cross the data boundary. - [ ] Thenable assimilation (objects with a `then` method are plain data, not promises). -- [ ] Custom promise construction with `new Promise(...)`. - [ ] Async iterables, host streams, and stream consumption. ## Objects and properties @@ -163,7 +168,7 @@ ultimate source of truth. - [ ] The mapper and `thisArg` forms of `Array.from`. - [ ] `Array.prototype.toSpliced`. - [ ] Canonical index handling: a key such as `"01"` must not alias index `1`. -- [ ] Complete sparse-array parity. +- [ ] Complete sparse-array parity. Promise combinators do consume holes as `undefined` members, as in JS. - [ ] Correct `findLast` return behavior when its predicate mutates the examined element. ## Strings @@ -268,6 +273,8 @@ ultimate source of truth. - [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with or without `new`. +- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by + an all-rejected `Promise.any`. - [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. - [x] `instanceof` for Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, Promise, and Error types. - [x] Catchable interpreter failures and awaited tool failures. diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 212e271e8d..6171b5e008 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -61,7 +61,7 @@ export class ComputedValue { export class PromiseNamespace {} -export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" +export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject" export class PromiseMethodReference { constructor(readonly name: PromiseMethodName) {} @@ -76,6 +76,12 @@ export class PromiseInstanceMethodReference { ) {} } +// The resolve/reject callables handed to a `new Promise(executor)` executor. `settle` closes +// over the promise's deferred and is first-settlement-wins; later calls are no-ops, as in JS. +export class PromiseCapabilityFunction { + constructor(readonly settle: (value: unknown) => void) {} +} + export type GlobalNamespaceName = | "Object" | "Math" @@ -131,7 +137,7 @@ export type DiagnosticKind = export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") export const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls, and promise chaining with .then/.catch/.finally." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/any/resolve/reject over arrays mixing promises and plain values for parallel tool calls, promise chaining with .then/.catch/.finally, and new Promise((resolve, reject) => ...) construction." export class InterpreterRuntimeError extends Error { readonly node?: AstNode diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 8e56849c5c..4c751d0142 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1,5 +1,5 @@ import { parse } from "acorn" -import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Scope, Semaphore } from "effect" import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" import { copyIn, @@ -42,6 +42,7 @@ import { isRecord, type MemberReference, OptionalShortCircuit, + PromiseCapabilityFunction, PromiseInstanceMethodReference, PromiseMethodReference, type PromiseMethodName, @@ -94,6 +95,7 @@ import { coerceToNumber, coerceToString, compoundOperators, + createAggregateErrorValue, createErrorValue, errorBrandName, errorConstructors, @@ -226,11 +228,22 @@ const settleAfterTurn = (body: Effect.Effect): Effect.Effect new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError") -type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction +// Short-circuit marker for Promise.any: the first fulfillment travels the error channel of +// the flipped members so fail-fast Effect.all stops observing on it. +class PromiseAnyFulfilled { + constructor(readonly value: unknown) {} +} + +type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction | PromiseCapabilityFunction // Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough. const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => { - if (value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof UriFunction) { + if ( + value instanceof CodeModeFunction || + value instanceof CoercionFunction || + value instanceof UriFunction || + value instanceof PromiseCapabilityFunction + ) { return value } if (typeofValue(value) === "function") { @@ -250,6 +263,21 @@ const caughtErrorValue = (thrown: unknown): unknown => { return createErrorValue(name, normalizeError(thrown).message) } +// `new Error("msg")` and the no-new call form share this; AggregateError alone takes +// (errors, message?) with a required errors collection, as in JS. +const constructErrorValue = (name: string, args: Array, node: AstNode): SafeObject => { + if (name !== "AggregateError") return createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) + const errors = spreadItems(args[0]) + if (errors === undefined) { + throw new InterpreterRuntimeError( + "new AggregateError(...) expects an array of errors (e.g. new AggregateError(errors, message?)).", + node, + ).as("TypeError") + } + // Copy: spreadItems returns array input itself, and the error value must not alias caller data. + return createAggregateErrorValue([...errors], args[1] === undefined ? "" : coerceToString(args[1])) +} + const isRuntimeReference = (value: unknown): boolean => value instanceof CodeModeFunction || value instanceof ToolReference || @@ -262,6 +290,7 @@ const isRuntimeReference = (value: unknown): boolean => value instanceof SandboxPromise || value instanceof CoercionFunction || value instanceof UriFunction || + value instanceof PromiseCapabilityFunction || value instanceof ErrorConstructorReference || isSandboxValue(value) @@ -305,6 +334,7 @@ const typeofValue = (value: unknown): string => { value instanceof PromiseMethodReference || value instanceof PromiseInstanceMethodReference || value instanceof PromiseNamespace || + value instanceof PromiseCapabilityFunction || value instanceof ErrorConstructorReference ) return "function" @@ -1584,19 +1614,10 @@ class Interpreter { const argNodes = getArray(node, "arguments") const self = this if (name === "Promise") { - throw new InterpreterRuntimeError( - "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) + return Effect.flatMap(this.evaluateCallArguments(argNodes), (args) => self.constructPromise(args[0], node)) } if (errorConstructors.has(name)) { - return Effect.gen(function* () { - const arg = - argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined - return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) - }) + return Effect.map(this.evaluateCallArguments(argNodes), (args) => constructErrorValue(name, args, node)) } if (valueConstructors.has(name)) { return Effect.gen(function* () { @@ -2066,7 +2087,11 @@ class Interpreter { } // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. if (callable instanceof ErrorConstructorReference) { - return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) + return constructErrorValue(callable.name, args, node) + } + if (callable instanceof PromiseCapabilityFunction) { + callable.settle(args[0]) + return undefined } throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) }) @@ -2255,8 +2280,8 @@ class Interpreter { return this.createPromise(Effect.fail(new ProgramThrow(args[0]))) } - const items = spreadItems(args[0]) - if (items === undefined) { + const spread = spreadItems(args[0]) + if (spread === undefined) { return this.createPromise( Effect.fail( new InterpreterRuntimeError( @@ -2266,6 +2291,8 @@ class Interpreter { ), ) } + // Densify: JS combinator iteration reads sparse holes as undefined members; .map would skip them. + const items = Array.from(spread) // JS makes combinator members "handled" synchronously at the call - their rejections // belong to the aggregate from this moment, even ones settling before it runs. @@ -2331,6 +2358,29 @@ class Interpreter { // and is interrupted at normal completion (already observed) or by teardown. return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations)))) } + case "any": { + // De Morgan dual of Promise.all: members are flipped so the first fulfillment + // short-circuits fail-fast Effect.all, and all-rejected completes with the reasons + // in input order for the AggregateError. + const flipped = items.map((item) => + item instanceof SandboxPromise + ? Effect.flatMap(this.promises.await(item), (exit) => { + if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value)) + if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause) + return Effect.succeed(caughtErrorValue(Cause.squash(exit.cause))) + }) + : Effect.fail(new PromiseAnyFulfilled(item)), + ) + const body = Effect.all(flipped, { concurrency: "unbounded" }).pipe( + Effect.flatMap((reasons) => + Effect.fail(new ProgramThrow(createAggregateErrorValue(reasons, "All promises were rejected"))), + ), + Effect.catch((error) => + error instanceof PromiseAnyFulfilled ? Effect.succeed(error.value) : Effect.fail(error), + ), + ) + return this.createPromise(settleAfterTurn(body)) + } } } @@ -2405,6 +2455,43 @@ class Interpreter { ) } + // new Promise(executor): the promise's fiber awaits a Deferred that resolve/reject settle + // exactly once. The executor runs synchronously; its throw rejects the promise unless it + // already settled (JS swallows post-settlement executor throws). + private constructPromise(executor: unknown, node: AstNode): Effect.Effect { + if (!(executor instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError( + "new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).", + node, + ).as("TypeError") + } + const self = this + return Effect.gen(function* () { + const deferred = Deferred.makeUnsafe() + const box: { own?: SandboxPromise } = {} + const promise = yield* self.createPromise( + Effect.flatMap(Deferred.await(deferred), (value) => { + if (!(value instanceof SandboxPromise)) return Effect.succeed(value) + if (value === box.own) return Effect.fail(selfResolutionError(node)) + return self.settlePromise(value) + }), + ) + box.own = promise + const resolve = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.succeed(value)) + }) + const reject = new PromiseCapabilityFunction((value) => { + Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))) + }) + const executed = yield* Effect.exit(self.invokeFunction(executor, [resolve, reject])) + if (!Exit.isSuccess(executed)) { + if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause) + Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause))) + } + return promise + }) + } + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits) invocation.scopes = [...fn.capturedScopes, new Map()] @@ -2568,7 +2655,8 @@ class Interpreter { if ( !(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction) && - !(callback instanceof UriFunction) + !(callback instanceof UriFunction) && + !(callback instanceof PromiseCapabilityFunction) ) { throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) } @@ -2577,7 +2665,9 @@ class Interpreter { ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) : callback instanceof UriFunction ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node)) - : this.invokeFunction(callback, callbackArgs) + : callback instanceof PromiseCapabilityFunction + ? Effect.sync(() => callback.settle(callbackArgs[0])) + : this.invokeFunction(callback, callbackArgs) } private invokeMapMethod( @@ -3185,7 +3275,7 @@ class Interpreter { return new PromiseMethodReference(key as PromiseMethodName) } throw new InterpreterRuntimeError( - `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.any, Promise.resolve, and Promise.reject; consume promises with await.`, propertyNode, ) } diff --git a/packages/codemode/src/stdlib/promise.ts b/packages/codemode/src/stdlib/promise.ts index d0b442ccf7..5a7cc5974b 100644 --- a/packages/codemode/src/stdlib/promise.ts +++ b/packages/codemode/src/stdlib/promise.ts @@ -1,6 +1,6 @@ import type { PromiseMethodName } from "../interpreter/model.js" -export const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) +export const promiseStatics = new Set(["all", "allSettled", "race", "any", "resolve", "reject"]) /** Maximum number of eagerly forked tool calls that may run concurrently. */ export const TOOL_CALL_CONCURRENCY = 8 diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index d9b85ea52d..7cdd8cc7dc 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -6,6 +6,7 @@ export const errorConstructors = new Set([ "ReferenceError", "EvalError", "URIError", + "AggregateError", ]) export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"]) @@ -20,6 +21,9 @@ export const createErrorValue = (name: string, message: string): SafeObject => { return value } +export const createAggregateErrorValue = (errors: Array, message: string): SafeObject => + Object.assign(createErrorValue("AggregateError", message), { errors }) + export const errorBrandName = (value: unknown): string | undefined => value !== null && typeof value === "object" ? ((value as Record)[ErrorBrand] as string | undefined) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7ff0c76b79..ea74930011 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -611,7 +611,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "## Language", "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", - "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and new Promise(...) are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Modules/imports, classes, generators, timers, fetch, eval, prototype access, and unlisted methods are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index b4c06b587c..c9ba85dab2 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -672,9 +672,10 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("not a general-purpose runtime") expect(instructions).not.toContain("Standard modern JavaScript works") expect(instructions).not.toContain("TypeScript type annotations") - for (const missing of ["Modules/imports", "classes", "generators", "fetch", "new Promise(...)"]) { + for (const missing of ["Modules/imports", "classes", "generators", "fetch"]) { expect(instructions).toContain(missing) } + expect(instructions).not.toContain("new Promise(...) are unavailable") expect(instructions).not.toContain("promise chaining") expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") diff --git a/packages/codemode/test/promise-test262.test.ts b/packages/codemode/test/promise-test262.test.ts index fa0eb246c1..e416ec942c 100644 --- a/packages/codemode/test/promise-test262.test.ts +++ b/packages/codemode/test/promise-test262.test.ts @@ -573,13 +573,14 @@ describe("Test262 async functions and await", () => { }) describe("Test262 expected Promise conformance", () => { - for (const name of ["all", "allSettled", "race"] as const) { + for (const name of ["all", "allSettled", "race", "any"] as const) { test(`Promise.${name} rejects invalid input with TypeError`, async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js // test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js // test/built-ins/Promise/allSettled/iter-arg-is-number-reject.js // test/built-ins/Promise/race/iter-arg-is-number-reject.js + // test/built-ins/Promise/any/iter-arg-is-number-reject.js expect( await value(` try { @@ -595,7 +596,7 @@ describe("Test262 expected Promise conformance", () => { }) } - test.failing("Promise.all consumes sparse positions as undefined", async () => { + test("Promise.all consumes sparse positions as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -607,7 +608,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([2, true, 1]) }) - test.failing("Promise.allSettled consumes sparse positions as undefined", async () => { + test("Promise.allSettled consumes sparse positions as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -619,7 +620,7 @@ describe("Test262 expected Promise conformance", () => { ).toEqual([2, "fulfilled", true, { status: "fulfilled", value: 1 }]) }) - test.failing("Promise.race consumes a sparse first position as undefined", async () => { + test("Promise.race consumes a sparse first position as undefined", async () => { // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) expect( await value(` @@ -630,6 +631,17 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) + test("Promise.any consumes a sparse first position as an undefined fulfillment", async () => { + // Source: test/built-ins/Array/from/from-array.js (array iterator hole behavior) + expect( + await value(` + const input = [] + input[1] = Promise.reject("loses") + return (await Promise.any(input)) === undefined + `), + ).toBe(true) + }) + test("Promise.all settles after reactions attached to its inputs", async () => { // Sources: // test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js @@ -1002,3 +1014,410 @@ describe("Test262 expected Promise conformance", () => { ).toBe(true) }) }) + +describe("Test262 Promise.any", () => { + test("is a callable static that returns a promise", async () => { + // Sources: + // test/built-ins/Promise/any/is-function.js + // test/built-ins/Promise/any/returns-promise.js + expect( + await value(` + const promise = Promise.any([1]) + return [typeof Promise.any, promise instanceof Promise, await promise] + `), + ).toEqual(["function", true, 1]) + }) + + test("fulfills with the first fulfilled member, ignoring rejections", async () => { + // Sources: + // test/built-ins/Promise/any/resolved-sequence-mixed.js + // test/built-ins/Promise/any/resolved-sequence-with-rejections.js + // test/built-ins/Promise/any/reject-ignored-immed.js + expect( + await value(` + return [ + await Promise.any([Promise.reject("a"), Promise.resolve(1), Promise.resolve(2)]), + await Promise.any([Promise.reject("a"), "plain", Promise.reject("b")]), + ] + `), + ).toEqual([1, "plain"]) + }) + + test("a fulfillment wins over a later rejection of another member", async () => { + // Sources: + // test/built-ins/Promise/any/resolve-ignores-late-rejection.js + // test/built-ins/Promise/any/resolve-ignores-late-rejection-deferred.js + expect( + await value(` + let rejectLate + const late = new Promise((_, reject) => { rejectLate = reject }) + const result = await Promise.any([late, Promise.resolve("won")]) + rejectLate("too late") + return result + `), + ).toBe("won") + }) + + test("rejects with an AggregateError carrying the reasons in input order", async () => { + // Sources: + // test/built-ins/Promise/any/reject-all-mixed.js + // test/built-ins/Promise/any/reject-immed.js + // test/built-ins/Promise/any/reject-deferred.js + expect( + await value(` + let rejectLate + const late = new Promise((_, reject) => { rejectLate = reject }) + const aggregate = Promise.any([Promise.reject("first"), late, Promise.reject("third")]) + rejectLate("second") + try { + await aggregate + return "fulfilled" + } catch (error) { + return { + isAggregate: error instanceof AggregateError, + isError: error instanceof Error, + name: error.name, + message: error.message, + errors: error.errors, + } + } + `), + ).toEqual({ + isAggregate: true, + isError: true, + name: "AggregateError", + message: "All promises were rejected", + errors: ["first", "second", "third"], + }) + }) + + test("rejects an empty input with an empty AggregateError", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-empty-iterable-reject.js + expect( + await value(` + try { + await Promise.any([]) + return "fulfilled" + } catch (error) { + return [error instanceof AggregateError, error.errors.length] + } + `), + ).toEqual([true, 0]) + }) + + test("consumes a string input as its characters", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-string-resolve.js + expect(await value(`return await Promise.any("abc")`)).toBe("a") + }) + + test("rejects an empty string input with an empty AggregateError", async () => { + // Source: test/built-ins/Promise/any/iter-arg-is-empty-string-reject.js + expect( + await value(` + try { + await Promise.any("") + return "fulfilled" + } catch (error) { + return [error instanceof AggregateError, error.errors.length] + } + `), + ).toEqual([true, 0]) + }) + + test("fulfills with the first member that does not reject", async () => { + // Sources: + // test/built-ins/Promise/any/resolve-from-reject-catch.js + // test/built-ins/Promise/any/resolve-from-resolve-reject-catch.js + expect( + await value(` + return await Promise.any([ + Promise.reject("a"), + new Promise((resolve, reject) => reject("b")), + Promise.all([Promise.reject("c")]), + Promise.resolve(Promise.reject("d").catch((reason) => reason)), + ]) + `), + ).toBe("d") + }) + + test("settles after reactions attached to its inputs", async () => { + // Source: test/built-ins/Promise/any/resolved-sequence.js + expect( + await value(` + const sequence = [1] + const input = Promise.resolve(1) + const aggregate = Promise.any([input]) + aggregate.then(() => sequence.push(4)) + input.then(() => sequence.push(3)).then(() => sequence.push(5)) + sequence.push(2) + await aggregate + await Promise.resolve() + return sequence + `), + ).toEqual([1, 2, 3, 4, 5]) + }) +}) + +describe("Test262 AggregateError", () => { + test("constructs from an errors collection and an optional message", async () => { + // Sources: + // test/built-ins/AggregateError/errors-iterabletolist.js + // test/built-ins/AggregateError/message-undefined-no-prop.js + expect( + await value(` + const input = ["x", "y"] + const withMessage = new AggregateError(input, "msg") + const bare = new AggregateError([]) + return [ + withMessage.name, + withMessage.message, + withMessage.errors, + withMessage.errors !== input, + withMessage instanceof AggregateError, + withMessage instanceof Error, + bare.message, + bare.errors, + ] + `), + ).toEqual(["AggregateError", "msg", ["x", "y"], true, true, true, "", []]) + }) + + test("rejects a non-collection errors argument with TypeError", async () => { + // Source: test/built-ins/AggregateError/errors-iterabletolist-failures.js + expect( + await value(` + try { + new AggregateError(42) + return "constructed" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) + + test("is callable without new", async () => { + // Source: test/built-ins/AggregateError/newtarget-is-undefined.js + expect( + await value(` + const error = AggregateError(["x"], "m") + return [error instanceof AggregateError, error instanceof Error, error.name, error.message, error.errors] + `), + ).toEqual([true, true, "AggregateError", "m", ["x"]]) + }) + + test("coerces a non-string message to a string", async () => { + // Source: test/built-ins/AggregateError/message-method-prop-cast.js (value coercion only; the + // upstream object-with-toString case is omitted because the sandbox has no user toString dispatch) + expect( + await value(` + return [ + new AggregateError([], 42).message, + new AggregateError([], false).message, + new AggregateError([], true).message, + new AggregateError([], null).message, + ] + `), + ).toEqual(["42", "false", "true", "null"]) + }) +}) + +describe("Test262 Promise constructor", () => { + test("constructs a promise, handing the executor callable resolve/reject", async () => { + // Sources: + // test/built-ins/Promise/constructor.js + // test/built-ins/Promise/exec-args.js + expect( + await value(` + let observed + const promise = new Promise((resolve, reject) => { + observed = [typeof resolve, typeof reject] + resolve("done") + }) + return [promise instanceof Promise, observed, await promise] + `), + ).toEqual([true, ["function", "function"], "done"]) + }) + + test("a missing or non-callable executor is a TypeError", async () => { + // Source: test/built-ins/Promise/executor-not-callable.js + expect( + await value(` + const outcomes = [] + for (const make of [() => new Promise(), () => new Promise(1), () => new Promise({})]) { + try { + make() + outcomes.push("constructed") + } catch (error) { + outcomes.push(error.name) + } + } + return outcomes + `), + ).toEqual(["TypeError", "TypeError", "TypeError"]) + }) + + test("resolves immediately or later through an escaping resolver", async () => { + // Sources: + // test/built-ins/Promise/resolve-non-thenable-immed.js + // test/built-ins/Promise/resolve-non-thenable-deferred.js + // test/built-ins/Promise/create-resolving-functions-resolve.js + expect( + await value(` + let settle + const deferred = new Promise((resolve) => { settle = resolve }) + const immediate = new Promise((resolve) => resolve("now")) + settle("later") + return [await immediate, await deferred] + `), + ).toEqual(["now", "later"]) + }) + + test("rejects through reject and through an abrupt executor completion", async () => { + // Sources: + // test/built-ins/Promise/reject-via-fn-immed.js + // test/built-ins/Promise/reject-via-abrupt.js + expect( + await value(` + const observe = async (promise) => { + try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason.message ?? reason] } + } + return [ + await observe(new Promise((_, reject) => reject("nope"))), + await observe(new Promise(() => { throw new Error("boom") })), + ] + `), + ).toEqual([ + ["rejected", "nope"], + ["rejected", "boom"], + ]) + }) + + test("only the first settlement counts", async () => { + // Sources: + // test/built-ins/Promise/reject-ignored-via-fn-immed.js + // test/built-ins/Promise/resolve-ignored-via-fn-immed.js + expect( + await value(` + return [ + await new Promise((resolve, reject) => { resolve("first"); reject("second"); resolve("third") }), + await new Promise((resolve) => { resolve(resolve("inner") === undefined ? "unreached" : "also unreached") }), + ] + `), + ).toEqual(["first", "inner"]) + }) + + test("escaped resolvers keep first-settle-wins in both directions", async () => { + // Sources: + // test/built-ins/Promise/reject-ignored-via-fn-deferred.js + // test/built-ins/Promise/resolve-ignored-via-fn-deferred.js + expect( + await value(` + let resolveRejected, rejectRejected + const rejected = new Promise((resolve, reject) => { resolveRejected = resolve; rejectRejected = reject }) + rejectRejected("first") + const lateResolve = resolveRejected("late") + let resolveFulfilled, rejectFulfilled + const fulfilled = new Promise((resolve, reject) => { resolveFulfilled = resolve; rejectFulfilled = reject }) + resolveFulfilled() + const lateReject = rejectFulfilled(new Promise(() => {})) + try { + await rejected + return "fulfilled" + } catch (reason) { + return [reason, lateResolve === undefined, (await fulfilled) === undefined, lateReject === undefined] + } + `), + ).toEqual(["first", true, true, true]) + }) + + test("a queued reaction chain observes a later rejection through a handler-less then", async () => { + // Sources: + // test/built-ins/Promise/reject-via-fn-immed-queue.js + // test/built-ins/Promise/reject-via-fn-deferred-queue.js + // test/built-ins/Promise/reject-via-abrupt-queue.js + expect( + await value(` + const observe = (promise) => promise.then(() => "wrong").then(() => "also wrong", (reason) => "caught:" + reason) + let reject + const deferred = new Promise((_, r) => { reject = r }) + const chained = observe(deferred) + reject("boom") + return [ + await observe(new Promise((_, r) => r("immed"))), + await chained, + await observe(new Promise(() => { throw "abrupt" })), + ] + `), + ).toEqual(["caught:immed", "caught:boom", "caught:abrupt"]) + }) + + test("an exception after resolve is ignored", async () => { + // Source: test/built-ins/Promise/exception-after-resolve-in-executor.js + expect(await value(`return await new Promise((resolve) => { resolve("kept"); throw new Error("dropped") })`)).toBe( + "kept", + ) + }) + + test("resolving with a promise adopts its settlement", async () => { + // Sources: + // test/built-ins/Promise/resolve-thenable-immed.js (promise-adoption portion) + // test/built-ins/Promise/all/S25.4.4.1_A2.3_T1.js (resolution adoption semantics) + expect( + await value(` + const adoptedValue = await new Promise((resolve) => resolve(Promise.resolve("adopted"))) + try { + await new Promise((resolve) => resolve(Promise.reject("bad"))) + return [adoptedValue, "fulfilled"] + } catch (reason) { + return [adoptedValue, reason] + } + `), + ).toEqual(["adopted", "bad"]) + }) + + test("resolving with the promise itself rejects with TypeError", async () => { + // Source: test/built-ins/Promise/resolve-self.js + expect( + await value(` + let settle + const promise = new Promise((resolve) => { settle = resolve }) + settle(promise) + try { + await promise + return "fulfilled" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) + + test("executor runs synchronously before the constructor returns", async () => { + // Source: test/built-ins/Promise/executor-call-context-strict.js (synchronous Call(executor) step) + expect( + await value(` + const sequence = [] + sequence.push("before") + new Promise((resolve) => { sequence.push("executor"); resolve() }) + sequence.push("after") + return sequence + `), + ).toEqual(["before", "executor", "after"]) + }) + + test.failing("calling Promise without new throws TypeError", async () => { + // Source: test/built-ins/Promise/undefined-newtarget.js + // The sandbox currently reports a generic Error ("Only tools are callable in CodeMode."). + expect( + await value(` + try { + Promise(() => {}) + return "called" + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index c74d6fd7df..5ce4d003d5 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -1062,15 +1062,179 @@ describe("unsupported promise surface", () => { }) test("unknown Promise statics list what is available", async () => { - const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`) - expect(diagnostic.message).toContain("Promise.any is not available") - expect(diagnostic.message).toContain("Promise.allSettled") + const diagnostic = await error(`return await Promise.withResolvers()`) + expect(diagnostic.message).toContain("Promise.withResolvers is not available") + expect(diagnostic.message).toContain("Promise.any") }) +}) - test("new Promise(...) points at tool calls instead", async () => { - const diagnostic = await error(`return new Promise((resolve) => resolve(1))`) - expect(diagnostic.kind).toBe("UnsupportedSyntax") - expect(diagnostic.message).toContain("new Promise(...) is not supported") - expect(diagnostic.message).toContain("already return promises") +describe("Promise.any", () => { + test("first tool success wins; failing and losing calls are handled silently", async () => { + const trace = makeTrace() + const result = await run( + ` + const winner = await Promise.any([ + tools.host.fail({}), + tools.host.sleepy({ id: 1, ms: 5 }), + tools.host.sleepy({ id: 2, ms: 60000 }), + ]) + return winner + `, + { trace }, + ) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe(1) + // The slow loser stays execution-owned and is interrupted at completion; the tool + // failure was observed by the aggregate, so no rejection warning survives. + expect(result.warnings).toBeUndefined() + expect(trace.interrupted).toBe(1) + }) + + test("all members failing rejects with catch-normalized reasons in input order", async () => { + expect( + await value(` + try { + await Promise.any([tools.host.fail({}), Promise.reject("plain")]) + return "fulfilled" + } catch (error) { + return [error.name, error.errors.map((reason) => reason.message ?? reason)] + } + `), + ).toEqual(["AggregateError", ["Lookup refused", "plain"]]) + }) + + test("settles one reaction turn after its deciding member, as in V8", async () => { + expect(await value(`return await Promise.race([Promise.any([Promise.resolve(1)]), Promise.resolve(2)])`)).toBe(2) + }) + + test("a tie is decided by settlement order, not input order", async () => { + // Handlers run in attach order, so `first` settles before `second` and wins + // despite its later input position - as in real JS. + expect( + await value(` + const first = Promise.resolve().then(() => "one") + const second = Promise.resolve().then(() => "two") + return await Promise.any([second, first]) + `), + ).toBe("one") + }) + + test("an abandoned rejecting aggregate is interrupted silently at the return", async () => { + const result = await run(` + Promise.any([Promise.reject(new Error("boom"))]) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) +}) + +describe("promise construction", () => { + test("a deferred gate coordinates tool results across async functions", async () => { + expect( + await value(` + let openGate + const gate = new Promise((resolve) => { openGate = resolve }) + const worker = (async () => { + const id = await gate + return id * 2 + })() + openGate(await tools.host.sleepy({ id: 21, ms: 5 })) + return await worker + `), + ).toBe(42) + }) + + test("the .then(resolve) bridge settles a constructed promise", async () => { + expect( + await value(` + const bridged = new Promise((resolve, reject) => { + tools.host.sleepy({ id: 7, ms: 5 }).then(resolve, reject) + }) + return await bridged + `), + ).toBe(7) + }) + + test("constructed promises participate in combinators", async () => { + expect( + await value(` + let settle + const manual = new Promise((resolve) => { settle = resolve }) + const race = Promise.race([manual, tools.host.sleepy({ id: 3, ms: 60000 })]) + const all = Promise.all([manual, "plain"]) + const any = Promise.any([manual, new Promise(() => {})]) + settle("manual") + return [await race, await all, await any] + `), + ).toEqual(["manual", ["manual", "plain"], "manual"]) + }) + + test("resolving with a pending promise adopts its later settlement", async () => { + expect( + await value(` + let innerResolve, innerReject + const adopted = new Promise((resolve) => resolve(new Promise((resolve) => { innerResolve = resolve }))) + const adoptedRejection = new Promise((resolve) => resolve(new Promise((_, reject) => { innerReject = reject }))) + innerResolve("later") + innerReject("bad") + try { + return [await adopted, await adoptedRejection] + } catch (reason) { + return [await adopted, reason] + } + `), + ).toEqual(["later", "bad"]) + }) + + test("an async executor's post-await resolve settles the promise", async () => { + expect( + await value(` + const result = new Promise(async (resolve) => { + const id = await tools.host.sleepy({ id: 5, ms: 5 }) + resolve(id * 2) + }) + return await result + `), + ).toBe(10) + }) + + test("a never-settled promise is abandoned silently at the return", async () => { + const result = await run(` + const forever = new Promise(() => {}) + forever.then(() => {}) + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toBeUndefined() + }) + + test("an un-awaited constructed rejection is reported like any unhandled rejection", async () => { + const result = await run(` + new Promise((_, reject) => reject(new Error("dropped"))) + await Promise.resolve() + await Promise.resolve() + return "done" + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("done") + expect(result.warnings).toHaveLength(1) + expect(result.warnings?.[0].message).toContain("Unhandled rejection") + expect(result.warnings?.[0].message).toContain("dropped") + }) + + test("resolver functions cannot cross the data boundary", async () => { + const diagnostic = await error(` + let escaped + new Promise((resolve) => { escaped = resolve }) + return { escaped } + `) + expect(diagnostic.kind).toBe("InvalidDataValue") }) })