diff --git a/lib/output/utils.js b/lib/output/utils.js index 5e509791..3994306b 100644 --- a/lib/output/utils.js +++ b/lib/output/utils.js @@ -198,6 +198,103 @@ function isAccessorDescriptor(desc) { return Object.hasOwn(desc, "get") || Object.hasOwn(desc, "set"); } +function getMethod(value, property, errPrefix = "The provided value") { + const func = value[property]; + if (func === undefined || func === null) { + return undefined; + } + if (typeof func !== "function") { + throw new TypeError(`${errPrefix}'s ${property} property is not a function.`); + } + return func; +} + +function createAsyncFromSyncIterator(syncIterator) { + // Instead of re-implementing CreateAsyncFromSyncIterator and %AsyncFromSyncIteratorPrototype%, + // we use yield* inside an async generator function to achieve the same result. + + // Wrap the sync iterator inside a sync iterable, so we can use it with yield*. + const syncIterable = { + [Symbol.iterator]: () => syncIterator + }; + // Create an async generator function and immediately invoke it. + const asyncIterator = (async function* () { + return yield* syncIterable; + })(); + // Return as an async iterator record. + return asyncIterator; +} + +function convertAsyncSequence(object, itemConverter, errPrefix = "The provided value") { + if (!isObject(object)) { + throw new TypeError(`${errPrefix} is not an object.`); + } + let method = getMethod(object, Symbol.asyncIterator, errPrefix); + let type = "async"; + if (method === undefined) { + method = getMethod(object, Symbol.iterator, errPrefix); + if (method === undefined) { + throw new TypeError(`${errPrefix} is not an async iterable object.`); + } + type = "sync"; + } + + return { + object, + method, + type, + // The wrapperSymbol ensures that if the async sequence is used as a return value, + // that it exposes the original JavaScript value. + // https://webidl.spec.whatwg.org/#js-async-iterable + [wrapperSymbol]: object, + // Implement the async iterator protocol, so users can iterate + // the async sequence directly (e.g. with for await...of) + // instead of needing to call a separate helper function to open the async sequence. + // https://webidl.spec.whatwg.org/#async-sequence-open + [Symbol.asyncIterator]() { + return openAsyncSequence(object, method, type, itemConverter, `${errPrefix}'s iterator`); + } + }; +} + +function openAsyncSequence(object, method, type, itemConverter, errPrefix = "The provided value") { + let iterator = call(method, object); + if (!isObject(iterator)) { + throw new TypeError(`${errPrefix}'s method must return an object`); + } + if (type === "sync") { + iterator = createAsyncFromSyncIterator(iterator); + } + const nextMethod = iterator.next; + return { + async next() { + const nextResult = await call(nextMethod, iterator); + if (!isObject(nextResult)) { + throw new TypeError(`${errPrefix}'s next method must return an object`); + } + const { done, value } = nextResult; + if (done) { + return { done: true, value: undefined }; + } + return { done: false, value: itemConverter(value) }; + }, + async return(reason) { + const returnMethod = getMethod(iterator, "return", errPrefix); + if (returnMethod === undefined) { + return { done: true, value: undefined }; + } + const returnResult = await call(returnMethod, iterator, reason); + if (!isObject(returnResult)) { + throw new TypeError(`${errPrefix}'s return method must return an object`); + } + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + } + }; +} + const supportsPropertyIndex = Symbol("supports property index"); const supportedPropertyIndices = Symbol("supported property indices"); const supportsPropertyName = Symbol("supports property name"); @@ -232,6 +329,8 @@ module.exports = exports = { isArrayBuffer, isSharedArrayBuffer, isArrayIndexPropName, + getMethod, + convertAsyncSequence, supportsPropertyIndex, supportedPropertyIndices, supportsPropertyName, diff --git a/lib/types.js b/lib/types.js index f90b5edb..5cd92815 100644 --- a/lib/types.js +++ b/lib/types.js @@ -138,6 +138,9 @@ function generateTypeConversion( if (idlType.union) { // union type generateUnion(); + } else if (idlType.generic === "async_sequence") { + // async_sequence type + generateAsyncSequence(); } else if (idlType.generic === "sequence") { // sequence type generateSequence(); @@ -278,14 +281,21 @@ function generateTypeConversion( let code = `if (utils.isObject(${name})) {`; if (union.sequenceLike) { - code += `if (${name}[Symbol.iterator] !== undefined) {`; + if (union.sequenceLike.generic === "async_sequence") { + code += `if ( + utils.getMethod(${name}, Symbol.asyncIterator, ${errPrefix}) !== undefined || + utils.getMethod(${name}, Symbol.iterator, ${errPrefix}) !== undefined + ) {`; + } else { + code += `if (utils.getMethod(${name}, Symbol.iterator, ${errPrefix}) !== undefined) {`; + } const conv = generateTypeConversion( ctx, name, union.sequenceLike, [], parentName, - `${errPrefix} + " sequence"` + `${errPrefix} + " ${union.sequenceLike.generic}"` ); requires.merge(conv.requires); code += conv.body; @@ -362,6 +372,29 @@ function generateTypeConversion( str += output.join(" else "); } + function generateAsyncSequence() { + const conv = generateTypeConversion( + ctx, + "item", + idlType.idlType[0], + [], + parentName, + `${errPrefix} + "'s element"` + ); + requires.merge(conv.requires); + + str += ` + ${name} = utils.convertAsyncSequence( + ${name}, + function (item) { + ${conv.body}; + return item; + }, + ${errPrefix} + ); + `; + } + function generateSequence() { const conv = generateTypeConversion( ctx, @@ -520,7 +553,7 @@ function extractUnionInfo(ctx, idlType, errPrefix) { unknown: false }; for (const item of idlType.idlType) { - if (item.generic === "sequence" || item.generic === "FrozenArray") { + if (item.generic === "sequence" || item.generic === "async_sequence" || item.generic === "FrozenArray") { if (seen.sequenceLike) { error("There can only be one sequence-like type in a union type"); } @@ -721,6 +754,12 @@ function sameArray(array1, array2, comparator = (x, y) => x === y) { return array1.length === array2.length && array1.every((element1, index) => comparator(element1, array2[index])); } +function isSequenceLike(type) { + return type.generic === "sequence" || + type.generic === "async_sequence" || + type.generic === "FrozenArray"; +} + function areDistinguishable(ctx, type1, type2) { const resolved1 = resolveType(ctx, type1); const resolved2 = resolveType(ctx, type2); @@ -775,8 +814,8 @@ function areDistinguishable(ctx, type1, type2) { const isDictionaryLike2 = ctx.dictionaries.has(inner2.idlType) || ctx.callbackInterfaces.has(inner2.idlType) || inner2.generic === "record"; - const isSequenceLike1 = inner1.generic === "sequence" || inner1.generic === "FrozenArray"; - const isSequenceLike2 = inner2.generic === "sequence" || inner2.generic === "FrozenArray"; + const isSequenceLike1 = isSequenceLike(inner1); + const isSequenceLike2 = isSequenceLike(inner2); if (inner1.idlType === "object") { return inner2.idlType !== "object" && diff --git a/test/__snapshots__/test.js.snap b/test/__snapshots__/test.js.snap index 4362fead..32413169 100644 --- a/test/__snapshots__/test.js.snap +++ b/test/__snapshots__/test.js.snap @@ -7257,6 +7257,67 @@ exports.install = (globalObject, globalNames) => { return esValue[implSymbol].sequenceConsumer2(...args); } + asyncSequenceConsumer(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequenceConsumer' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = conversions["USVString"](item, { + context: "Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': parameter 1" + "'s element", + globals: globalObject + }); + return item; + }, + "Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return esValue[implSymbol].asyncSequenceConsumer(...args); + } + + asyncSequenceConsumer2(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequenceConsumer2' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequenceConsumer2' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = utils.tryImplForWrapper(item); + return item; + }, + "Failed to execute 'asyncSequenceConsumer2' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return esValue[implSymbol].asyncSequenceConsumer2(...args); + } + frozenArrayConsumer(arr) { const esValue = this !== null && this !== undefined ? this : globalObject; if (!exports.is(esValue)) { @@ -7295,13 +7356,48 @@ exports.install = (globalObject, globalNames) => { } return esValue[implSymbol].frozenArrayConsumer(...args); } + + asyncSequencePassthrough(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequencePassthrough' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = conversions["double"](item, { + context: "Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': parameter 1" + "'s element", + globals: globalObject + }); + return item; + }, + "Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return utils.tryWrapperForImpl(esValue[implSymbol].asyncSequencePassthrough(...args)); + } } Object.defineProperties(SeqAndRec.prototype, { recordConsumer: { enumerable: true }, recordConsumer2: { enumerable: true }, sequenceConsumer: { enumerable: true }, sequenceConsumer2: { enumerable: true }, + asyncSequenceConsumer: { enumerable: true }, + asyncSequenceConsumer2: { enumerable: true }, frozenArrayConsumer: { enumerable: true }, + asyncSequencePassthrough: { enumerable: true }, [Symbol.toStringTag]: { value: "SeqAndRec", configurable: true } }); ctorRegistry[interfaceName] = SeqAndRec; @@ -10089,7 +10185,10 @@ exports.install = (globalObject, globalNames) => { let curArg = arguments[0]; if (curArg !== undefined) { if (utils.isObject(curArg)) { - if (curArg[Symbol.iterator] !== undefined) { + if ( + utils.getMethod(curArg, Symbol.iterator, "Failed to construct 'URLSearchParams': parameter 1") !== + undefined + ) { if (!utils.isObject(curArg)) { throw new globalObject.TypeError( "Failed to construct 'URLSearchParams': parameter 1" + " sequence" + " is not an iterable object." @@ -19028,6 +19127,67 @@ exports.install = (globalObject, globalNames) => { return esValue[implSymbol].sequenceConsumer2(...args); } + asyncSequenceConsumer(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequenceConsumer' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = conversions["USVString"](item, { + context: "Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': parameter 1" + "'s element", + globals: globalObject + }); + return item; + }, + "Failed to execute 'asyncSequenceConsumer' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return esValue[implSymbol].asyncSequenceConsumer(...args); + } + + asyncSequenceConsumer2(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequenceConsumer2' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequenceConsumer2' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = utils.tryImplForWrapper(item); + return item; + }, + "Failed to execute 'asyncSequenceConsumer2' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return esValue[implSymbol].asyncSequenceConsumer2(...args); + } + frozenArrayConsumer(arr) { const esValue = this !== null && this !== undefined ? this : globalObject; if (!exports.is(esValue)) { @@ -19066,13 +19226,48 @@ exports.install = (globalObject, globalNames) => { } return esValue[implSymbol].frozenArrayConsumer(...args); } + + asyncSequencePassthrough(async_seq) { + const esValue = this !== null && this !== undefined ? this : globalObject; + if (!exports.is(esValue)) { + throw new globalObject.TypeError( + "'asyncSequencePassthrough' called on an object that is not a valid instance of SeqAndRec." + ); + } + + if (arguments.length < 1) { + throw new globalObject.TypeError( + \`Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': 1 argument required, but only \${arguments.length} present.\` + ); + } + const args = []; + { + let curArg = arguments[0]; + curArg = utils.convertAsyncSequence( + curArg, + function (item) { + item = conversions["double"](item, { + context: "Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': parameter 1" + "'s element", + globals: globalObject + }); + return item; + }, + "Failed to execute 'asyncSequencePassthrough' on 'SeqAndRec': parameter 1" + ); + args.push(curArg); + } + return utils.tryWrapperForImpl(esValue[implSymbol].asyncSequencePassthrough(...args)); + } } Object.defineProperties(SeqAndRec.prototype, { recordConsumer: { enumerable: true }, recordConsumer2: { enumerable: true }, sequenceConsumer: { enumerable: true }, sequenceConsumer2: { enumerable: true }, + asyncSequenceConsumer: { enumerable: true }, + asyncSequenceConsumer2: { enumerable: true }, frozenArrayConsumer: { enumerable: true }, + asyncSequencePassthrough: { enumerable: true }, [Symbol.toStringTag]: { value: "SeqAndRec", configurable: true } }); ctorRegistry[interfaceName] = SeqAndRec; @@ -21860,7 +22055,10 @@ exports.install = (globalObject, globalNames) => { let curArg = arguments[0]; if (curArg !== undefined) { if (utils.isObject(curArg)) { - if (curArg[Symbol.iterator] !== undefined) { + if ( + utils.getMethod(curArg, Symbol.iterator, "Failed to construct 'URLSearchParams': parameter 1") !== + undefined + ) { if (!utils.isObject(curArg)) { throw new globalObject.TypeError( "Failed to construct 'URLSearchParams': parameter 1" + " sequence" + " is not an iterable object." diff --git a/test/cases/SeqAndRec.webidl b/test/cases/SeqAndRec.webidl index f3b24ce9..42a694fb 100644 --- a/test/cases/SeqAndRec.webidl +++ b/test/cases/SeqAndRec.webidl @@ -6,5 +6,9 @@ interface SeqAndRec { undefined recordConsumer2(record rec); undefined sequenceConsumer(sequence seq); undefined sequenceConsumer2(sequence seq); + undefined asyncSequenceConsumer(async_sequence async_seq); + undefined asyncSequenceConsumer2(async_sequence async_seq); undefined frozenArrayConsumer(FrozenArray arr); + + async_sequence asyncSequencePassthrough(async_sequence async_seq); }; diff --git a/test/utils.test.js b/test/utils.test.js index 1cb4fa66..963dd1c5 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -49,4 +49,190 @@ describe("utils.js", () => { expect(object).not.toBeInstanceOf(realm.Object); }); }); + + describe("convertAsyncSequence", () => { + const iterableFactories = [ + [ + "an array of values", () => { + return ["a", "b"]; + } + ], + + [ + "an array of promises", () => { + return [ + Promise.resolve("a"), + Promise.resolve("b") + ]; + } + ], + + [ + "an array iterator", () => { + return ["a", "b"][Symbol.iterator](); + } + ], + + [ + "a Set", () => { + return new Set(["a", "b"]); + } + ], + + [ + "a Set iterator", () => { + return new Set(["a", "b"])[Symbol.iterator](); + } + ], + + [ + "a sync generator", () => { + function* syncGenerator() { + yield "a"; + yield "b"; + } + + return syncGenerator(); + } + ], + + [ + "an async generator", () => { + async function* asyncGenerator() { + yield "a"; + yield "b"; + } + + return asyncGenerator(); + } + ], + + [ + "a sync iterable of values", () => { + const chunks = ["a", "b"]; + const iterator = { + next() { + return { + done: chunks.length === 0, + value: chunks.shift() + }; + } + }; + const iterable = { + [Symbol.iterator]: () => iterator + }; + return iterable; + } + ], + + [ + "a sync iterable of promises", () => { + const chunks = ["a", "b"]; + const iterator = { + next() { + return chunks.length === 0 ? + { done: true } : + { + done: false, + value: Promise.resolve(chunks.shift()) + }; + } + }; + const iterable = { + [Symbol.iterator]: () => iterator + }; + return iterable; + } + ], + + [ + "an async iterable", () => { + const chunks = ["a", "b"]; + const asyncIterator = { + next() { + return Promise.resolve({ + done: chunks.length === 0, + value: chunks.shift() + }); + } + }; + const asyncIterable = { + [Symbol.asyncIterator]: () => asyncIterator + }; + return asyncIterable; + } + ] + ]; + + for (const [label, factory] of iterableFactories) { + test(`accepts ${label}`, async () => { + const iterable = factory(); + const isAsync = label.includes("async"); + const asyncSequence = utils.convertAsyncSequence(iterable, x => x); + + expect(asyncSequence.object).toBe(iterable); + expect(asyncSequence.method).toBe(isAsync ? iterable[Symbol.asyncIterator] : iterable[Symbol.iterator]); + expect(asyncSequence.type).toBe(isAsync ? "async" : "sync"); + expect(utils.wrapperForImpl(asyncSequence)).toBe(iterable); + + const iterator = asyncSequence[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ done: false, value: "a" }); + await expect(iterator.next()).resolves.toEqual({ done: false, value: "b" }); + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); + }); + } + + const badIterables = [ + ["null", null], + ["undefined", undefined], + ["0", 0], + ["NaN", NaN], + ["true", true], + ["{}", {}], + ["Object.create(null)", Object.create(null)], + ["a function", () => 42], + ["a symbol", Symbol("foo")], + ["a string", "ab"], + [ + "an object with a non-callable @@iterator method", { + [Symbol.iterator]: 42 + } + ], + [ + "an object with a non-callable @@asyncIterator method", { + [Symbol.asyncIterator]: 42 + } + ] + ]; + + for (const [label, iterable] of badIterables) { + test(`throws on invalid iterables; specifically ${label}`, () => { + expect(() => { + utils.convertAsyncSequence(iterable, x => x); + }).toThrow(TypeError); + }); + } + + const badIteratorMethods = [ + [ + "an object with an @@iterator method returning a non-object", { + [Symbol.iterator]: () => 42 + } + ], + [ + "an object with an @@asyncIterator method returning a non-object", { + [Symbol.asyncIterator]: () => 42 + } + ] + ]; + + for (const [label, iterable] of badIteratorMethods) { + test(`throws when opening iterables with bad iterator methods; specifically ${label}`, () => { + const asyncSequence = utils.convertAsyncSequence(iterable, x => x); + expect(() => { + asyncSequence[Symbol.asyncIterator](); + }).toThrow(TypeError); + }); + } + }); });