From 42aad300dcf6857758c80f8cc549322199517d83 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Wed, 4 Oct 2023 18:49:19 +0200 Subject: [PATCH 1/2] feat(async-rewriter): allow cursor iteration with for-of MONGOSH-1527 --- .../src/async-writer-babel.spec.ts | 18 +- .../src/stages/transform-maybe-await.ts | 193 +++++++++++++++--- packages/shell-api/src/abstract-cursor.ts | 4 + .../shell-api/src/change-stream-cursor.ts | 4 + 4 files changed, 190 insertions(+), 29 deletions(-) diff --git a/packages/async-rewriter2/src/async-writer-babel.spec.ts b/packages/async-rewriter2/src/async-writer-babel.spec.ts index 9414d54fb..fc5c4847f 100644 --- a/packages/async-rewriter2/src/async-writer-babel.spec.ts +++ b/packages/async-rewriter2/src/async-writer-babel.spec.ts @@ -390,7 +390,7 @@ describe('AsyncWriter', function () { expect(implicitlyAsyncFn).to.have.callCount(10); }); - it('can use for loops as weird assignments', async function () { + it('can use for loops as weird assignments (sync)', async function () { const obj = { foo: null }; implicitlyAsyncFn.resolves(obj); await runTranspiledCode( @@ -400,6 +400,16 @@ describe('AsyncWriter', function () { expect(obj.foo).to.equal('bar'); }); + it('can use for loops as weird assignments (async)', async function () { + const obj = { foo: null }; + implicitlyAsyncFn.resolves(obj); + await runTranspiledCode( + '(async() => { for await (implicitlyAsyncFn().foo of ["foo", "bar"]); })()' + ); + expect(implicitlyAsyncFn).to.have.callCount(2); + expect(obj.foo).to.equal('bar'); + }); + it('works with assignments to objects', async function () { implicitlyAsyncFn.resolves({ foo: 'bar' }); const ret = runTranspiledCode(` @@ -995,16 +1005,16 @@ describe('AsyncWriter', function () { expect(() => runTranspiledCode('var db = {}; db.testx();')).to.throw( 'db.testx is not a function' ); - // (Note: The following ones would give better error messages in regular code) + // (Note: The following one would give better error messages in regular code) expect(() => runTranspiledCode('var db = {}; new Promise(db.foo)') ).to.throw('Promise resolver undefined is not a function'); expect(() => runTranspiledCode('var db = {}; for (const a of db.foo) {}') - ).to.throw(/undefined is not iterable/); + ).to.throw(/db.foo is not iterable/); expect(() => runTranspiledCode('var db = {}; for (const a of db[0]) {}') - ).to.throw(/undefined is not iterable/); + ).to.throw(/db\[0\] is not iterable/); expect(() => runTranspiledCode('for (const a of 8) {}')).to.throw( '8 is not iterable' ); diff --git a/packages/async-rewriter2/src/stages/transform-maybe-await.ts b/packages/async-rewriter2/src/stages/transform-maybe-await.ts index 788800200..3ede65f21 100644 --- a/packages/async-rewriter2/src/stages/transform-maybe-await.ts +++ b/packages/async-rewriter2/src/stages/transform-maybe-await.ts @@ -28,7 +28,9 @@ interface AsyncFunctionIdentifiers { expressionHolder: babel.types.Identifier; markSyntheticPromise: babel.types.Identifier; isSyntheticPromise: babel.types.Identifier; + adaptAsyncIterableToSyncIterable: babel.types.Identifier; syntheticPromiseSymbol: babel.types.Identifier; + syntheticAsyncIterableSymbol: babel.types.Identifier; demangleError: babel.types.Identifier; assertNotSyntheticPromise: babel.types.Identifier; } @@ -45,6 +47,7 @@ export default ({ const isGeneratedInnerFunction = asNodeKey( Symbol('isGeneratedInnerFunction') ); + const isWrappedForOfLoop = asNodeKey(Symbol('isWrappedForOfLoop')); const isGeneratedHelper = asNodeKey(Symbol('isGeneratedHelper')); const isOriginalBody = asNodeKey(Symbol('isOriginalBody')); const isAlwaysSyncFunction = asNodeKey(Symbol('isAlwaysSyncFunction')); @@ -82,6 +85,47 @@ export default ({ } `); + const adaptAsyncIterableToSyncIterableTemplate = babel.template.statement(` + function AAITSI_IDENTIFIER(original) { + const SAI_IDENTIFIER = Symbol.for("@@mongosh.syntheticAsyncIterable"); + if (!original || !original[SAI_IDENTIFIER]) { + return { iterable: original, isSyntheticAsyncIterable: false }; + } + const originalIterator = original[Symbol.asyncIterator](); + let next; + let returned; + + return { + isSyntheticAsyncIterable: true, + iterable: { + [Symbol.iterator]() { + return this; + }, + next() { + let _next = next; + next = undefined; + return _next; + }, + return(value) { + returned = { value }; + return { + value, + done: true + } + }, + async expectNext() { + next ??= await originalIterator.next(); + }, + async syncReturn() { + if (returned) { + await originalIterator.return(returned.value); + } + } + } + } + } + `); + const asyncTryCatchWrapperTemplate = babel.template.expression(` async () => { try { @@ -137,6 +181,26 @@ export default ({ } `); + const forOfLoopTemplate = babel.template.statement(`{ + const ITERABLE_INFO = AAITSI_IDENTIFIER(ORIGINAL_ITERABLE); + const ITERABLE_ISAI = (ITERABLE_INFO).isSyntheticAsyncIterable; + const ITERABLE = (ITERABLE_INFO).iterable; + + try { + ITERABLE_ISAI && await (ITERABLE).expectNext(); + for (const ITEM of (ORIGINAL_ITERABLE_SOURCE, ITERABLE)) { + ORIGINAL_DECLARATION; + try { + ORIGINAL_BODY; + } finally { + ITERABLE_ISAI && await (ITERABLE).expectNext(); + } + } + } finally { + ITERABLE_ISAI && await (ITERABLE).syncReturn(); + } + }`); + // If we encounter an error object, we fix up the error message from something // like `("a" , foo(...)(...)) is not a function` to `a is not a function`. // For that, we look for a) the U+FEFF markers we use to tag the original source @@ -164,6 +228,34 @@ export default ({ FUNCTION_STATE_IDENTIFIER === 'async' ? SYNC_RETURN_VALUE_IDENTIFIER : null )`); + // Transform expression `foo` into + // `('\uFEFFfoo\uFEFF', ex = foo, isSyntheticPromise(ex) ? await ex : ex)` + // The first part of the sequence expression is used to identify this + // expression for re-writing error messages, so that we can transform + // TypeError: ((intermediate value)(intermediate value) , (intermediate value)(intermediate value)(intermediate value)).findx is not a function + // back into + // TypeError: db.test.findx is not a function + // The U+FEFF markers are only used to rule out any practical chance of + // user code accidentally being recognized as the original source code. + // We limit the string length so that long expressions (e.g. those + // containing functions) are not included in full length. + function getOriginalSourceString( + { file }: { file: babel.BabelFile }, + node: babel.Node, + { wrap = true } = {} + ): babel.types.StringLiteral { + const prettyOriginalString = limitStringLength( + node.start !== undefined + ? file.code.slice(node.start ?? undefined, node.end ?? undefined) + : '', + 24 + ); + + if (!wrap) return t.stringLiteral(prettyOriginalString); + + return t.stringLiteral('\ufeff' + prettyOriginalString + '\ufeff'); + } + return { pre(file: babel.BabelFile) { this.file = file; @@ -212,12 +304,18 @@ export default ({ const isSyntheticPromise = existingIdentifiers?.isSyntheticPromise ?? path.scope.generateUidIdentifier('isp'); + const adaptAsyncIterableToSyncIterable = + existingIdentifiers?.adaptAsyncIterableToSyncIterable ?? + path.scope.generateUidIdentifier('aaitsi'); const assertNotSyntheticPromise = existingIdentifiers?.assertNotSyntheticPromise ?? path.scope.generateUidIdentifier('ansp'); const syntheticPromiseSymbol = existingIdentifiers?.syntheticPromiseSymbol ?? path.scope.generateUidIdentifier('sp'); + const syntheticAsyncIterableSymbol = + existingIdentifiers?.syntheticAsyncIterableSymbol ?? + path.scope.generateUidIdentifier('sai'); const demangleError = existingIdentifiers?.demangleError ?? path.scope.generateUidIdentifier('de'); @@ -228,8 +326,10 @@ export default ({ expressionHolder, markSyntheticPromise, isSyntheticPromise, + adaptAsyncIterableToSyncIterable, assertNotSyntheticPromise, syntheticPromiseSymbol, + syntheticAsyncIterableSymbol, demangleError, }; path.parentPath.setData(identifierGroupKey, identifiersGroup); @@ -273,6 +373,13 @@ export default ({ }), { [isGeneratedHelper]: true } ), + Object.assign( + adaptAsyncIterableToSyncIterableTemplate({ + AAITSI_IDENTIFIER: adaptAsyncIterableToSyncIterable, + SAI_IDENTIFIER: syntheticAsyncIterableSymbol, + }), + { [isGeneratedHelper]: true } + ), Object.assign( isSyntheticPromiseTemplate({ ISP_IDENTIFIER: isSyntheticPromise, @@ -556,22 +663,15 @@ export default ({ isSyntheticPromise, assertNotSyntheticPromise, } = identifierGroup; - const prettyOriginalString = limitStringLength( - path.node.start !== undefined - ? this.file.code.slice( - path.node.start ?? undefined, - path.node.end ?? undefined - ) - : '', - 24 - ); if (!functionParent.node.async) { // Transform expression `foo` into `assertNotSyntheticPromise(foo, 'foo')`. path.replaceWith( Object.assign( assertNotSyntheticExpressionTemplate({ - ORIGINAL_SOURCE: t.stringLiteral(prettyOriginalString), + ORIGINAL_SOURCE: getOriginalSourceString(this, path.node, { + wrap: false, + }), NODE: path.node, ANSP_IDENTIFIER: assertNotSyntheticPromise, }), @@ -581,24 +681,10 @@ export default ({ return; } - // Transform expression `foo` into - // `('\uFEFFfoo\uFEFF', ex = foo, isSyntheticPromise(ex) ? await ex : ex)` - // The first part of the sequence expression is used to identify this - // expression for re-writing error messages, so that we can transform - // TypeError: ((intermediate value)(intermediate value) , (intermediate value)(intermediate value)(intermediate value)).findx is not a function - // back into - // TypeError: db.test.findx is not a function - // The U+FEFF markers are only used to rule out any practical chance of - // user code accidentally being recognized as the original source code. - // We limit the string length so that long expressions (e.g. those - // containing functions) are not included in full length. - const originalSource = t.stringLiteral( - '\ufeff' + prettyOriginalString + '\ufeff' - ); path.replaceWith( Object.assign( awaitSyntheticPromiseTemplate({ - ORIGINAL_SOURCE: originalSource, + ORIGINAL_SOURCE: getOriginalSourceString(this, path.node), EXPRESSION_HOLDER: expressionHolder, ISP_IDENTIFIER: isSyntheticPromise, NODE: path.node, @@ -645,6 +731,63 @@ export default ({ ); }, }, + ForOfStatement(path) { + if (path.node.await) return; + + if ( + path.find( + (path) => path.isFunction() || !!path.node[isGeneratedHelper] + )?.node?.[isGeneratedHelper] + ) { + return path.skip(); + } + + if ( + path.find( + (path) => path.isFunction() || !!path.node[isWrappedForOfLoop] + )?.node?.[isWrappedForOfLoop] + ) { + return; + } + + const identifierGroup: AsyncFunctionIdentifiers | null = path + .findParent((path) => !!path.getData(identifierGroupKey)) + ?.getData(identifierGroupKey); + if (!identifierGroup) + throw new Error('Missing identifier group for ForOfStatement'); + const { adaptAsyncIterableToSyncIterable } = identifierGroup; + const item = path.scope.generateUidIdentifier('i'); + path.replaceWith( + Object.assign( + forOfLoopTemplate({ + ORIGINAL_ITERABLE: path.node.right, + ORIGINAL_ITERABLE_SOURCE: getOriginalSourceString( + this, + path.node.right + ), + ORIGINAL_DECLARATION: + path.node.left.type === 'VariableDeclaration' + ? t.variableDeclaration( + path.node.left.kind, + path.node.left.declarations.map((d) => ({ + ...d, + init: item, + })) + ) + : t.expressionStatement( + t.assignmentExpression('=', path.node.left, item) + ), + ORIGINAL_BODY: path.node.body, + ITERABLE_INFO: path.scope.generateUidIdentifier('ii'), + ITERABLE_ISAI: path.scope.generateUidIdentifier('isai'), + ITERABLE: path.scope.generateUidIdentifier('it'), + ITEM: item, + AAITSI_IDENTIFIER: adaptAsyncIterableToSyncIterable, + }), + { [isWrappedForOfLoop]: true } + ) + ); + }, }, }; }; diff --git a/packages/shell-api/src/abstract-cursor.ts b/packages/shell-api/src/abstract-cursor.ts index 036d6fad5..b2f5e65f7 100644 --- a/packages/shell-api/src/abstract-cursor.ts +++ b/packages/shell-api/src/abstract-cursor.ts @@ -90,6 +90,10 @@ export abstract class AbstractCursor< return result; } + get [Symbol.for('@@mongosh.syntheticAsyncIterable')]() { + return true; + } + async *[Symbol.asyncIterator]() { let doc; // !== null should suffice, but some stubs in our tests return 'undefined' diff --git a/packages/shell-api/src/change-stream-cursor.ts b/packages/shell-api/src/change-stream-cursor.ts index 599986296..89df34ada 100644 --- a/packages/shell-api/src/change-stream-cursor.ts +++ b/packages/shell-api/src/change-stream-cursor.ts @@ -72,6 +72,10 @@ export default class ChangeStreamCursor extends ShellApiWithMongoClass { return this._cursor.tryNext(); } + get [Symbol.for('@@mongosh.syntheticAsyncIterable')]() { + return true; + } + async *[Symbol.asyncIterator]() { let doc; while ((doc = await this.tryNext()) !== null) { From 906d16fe735ec8183aa667dcb66e89a015ac9298 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 5 Oct 2023 14:37:47 +0200 Subject: [PATCH 2/2] fixup: more tests, `yield*`, etc. --- .../src/async-writer-babel.spec.ts | 97 ++++++++++++++++++- packages/async-rewriter2/src/error-codes.ts | 21 ++++ .../src/stages/transform-maybe-await.ts | 56 ++++++----- packages/e2e-tests/test/e2e.spec.ts | 13 +++ 4 files changed, 162 insertions(+), 25 deletions(-) diff --git a/packages/async-rewriter2/src/async-writer-babel.spec.ts b/packages/async-rewriter2/src/async-writer-babel.spec.ts index fc5c4847f..4a3527662 100644 --- a/packages/async-rewriter2/src/async-writer-babel.spec.ts +++ b/packages/async-rewriter2/src/async-writer-babel.spec.ts @@ -57,6 +57,24 @@ describe('AsyncWriter', function () { [Symbol.for('@@mongosh.uncatchable')]: true, }); }, + regularIterable: function* () { + yield* [1, 2, 3]; + }, + regularAsyncIterable: async function* () { + await Promise.resolve(); + yield* [1, 2, 3]; + }, + implicitlyAsyncIterable: function () { + return Object.assign( + (async function* () { + await Promise.resolve(); + yield* [1, 2, 3]; + })(), + { + [Symbol.for('@@mongosh.syntheticAsyncIterable')]: true, + } + ); + }, }); runTranspiledCode = (code: string, context?: any) => { const transpiled = asyncWriter.process(code); @@ -543,6 +561,44 @@ describe('AsyncWriter', function () { expect(await ret).to.equal('bar'); }); + context('for-of', function () { + it('can iterate over implicit iterables', async function () { + expect( + await runTranspiledCode(`(function() { + let sum = 0; + for (const value of implicitlyAsyncIterable()) + sum += value; + return sum; + })()`) + ).to.equal(6); + }); + + it('can iterate over implicit iterables in async functions', async function () { + expect( + await runTranspiledCode(`(async function() { + let sum = 0; + for (const value of implicitlyAsyncIterable()) + sum += value; + return sum; + })()`) + ).to.equal(6); + }); + + it('can implicitly yield* inside of async generator functions', async function () { + expect( + await runTranspiledCode(`(async function() { + const gen = (async function*() { + yield* implicitlyAsyncIterable(); + })(); + let sum = 0; + for await (const value of gen) + sum += value; + return sum; + })()`) + ).to.equal(6); + }); + }); + context('invalid implicit awaits', function () { beforeEach(function () { runUntranspiledCode(asyncWriter.runtimeSupportCode()); @@ -594,6 +650,45 @@ describe('AsyncWriter', function () { '[ASYNC-10012] Result of expression "compareFn(...args)" cannot be used in this context' ); }); + + context('for-of', function () { + it('cannot implicitly yield* inside of generator functions', function () { + expect(() => + runTranspiledCode(`(function() { + const gen = (function*() { + yield* implicitlyAsyncIterable(); + })(); + for (const value of gen) return value; + })()`) + ).to.throw( + '[ASYNC-10013] Result of expression "implicitlyAsyncIterable()" cannot be iterated in this context' + ); + }); + + it('cannot implicitly for-of inside of generator functions', function () { + expect(() => + runTranspiledCode(`(function() { + const gen = (function*() { + for (const item of implicitlyAsyncIterable()) yield item; + })(); + for (const value of gen) return value; + })()`) + ).to.throw( + '[ASYNC-10013] Result of expression "implicitlyAsyncIterable()" cannot be iterated in this context' + ); + }); + + it('cannot implicitly for-of await inside of class constructors', function () { + expect( + () => + runTranspiledCode(`class A { + constructor() { for (this.foo of implicitlyAsyncIterable()) {} } + }; new A()`).value + ).to.throw( + '[ASYNC-10013] Result of expression "implicitlyAsyncIterable()" cannot be iterated in this context' + ); + }); + }); }); }); @@ -1040,7 +1135,7 @@ describe('AsyncWriter', function () { runTranspiledCode( 'globalThis.abcdefghijklmnopqrstuvwxyz = {}; abcdefghijklmnopqrstuvwxyz()' ) - ).to.throw('abcdefghijklm ... uvwxyz is not a function'); + ).to.throw('abcdefghijklmn ... uvwxyz is not a function'); }); }); diff --git a/packages/async-rewriter2/src/error-codes.ts b/packages/async-rewriter2/src/error-codes.ts index 218b647e4..c3abd6714 100644 --- a/packages/async-rewriter2/src/error-codes.ts +++ b/packages/async-rewriter2/src/error-codes.ts @@ -24,6 +24,27 @@ enum AsyncRewriterErrors { * **Solution: Do not use calls directly in such functions. If necessary, place these calls in an inner 'async' function.** */ SyntheticPromiseInAlwaysSyncContext = 'ASYNC-10012', + /** + * Signals the iteration of a Mongosh API object in a place where it is not supported. + * This occurs inside of constructors and (non-async) generator functions. + * + * Examples causing error: + * ```javascript + * class SomeClass { + * constructor() { + * for (const item of db.coll.find()) { ... } + * } + * } + * + * function*() { + * for (const item of db.coll.find()) yield item; + * yield* db.coll.find(); + * } + * ``` + * + * **Solution: Do not use calls directly in such functions. If necessary, place these calls in an inner 'async' function.** + */ + SyntheticAsyncIterableInAlwaysSyncContext = 'ASYNC-10013', } export { AsyncRewriterErrors }; diff --git a/packages/async-rewriter2/src/stages/transform-maybe-await.ts b/packages/async-rewriter2/src/stages/transform-maybe-await.ts index 3ede65f21..b03c5d182 100644 --- a/packages/async-rewriter2/src/stages/transform-maybe-await.ts +++ b/packages/async-rewriter2/src/stages/transform-maybe-await.ts @@ -56,8 +56,9 @@ export default ({ // of helpers which are available inside the function. const identifierGroupKey = '@@mongosh.identifierGroup'; - const syntheticPromiseSymbolTemplate = babel.template.statement(` + const syntheticPromiseSymbolTemplate = babel.template.statements(` const SP_IDENTIFIER = Symbol.for("@@mongosh.syntheticPromise"); + const SAI_IDENTIFIER = Symbol.for("@@mongosh.syntheticAsyncIterable"); `); const markSyntheticPromiseTemplate = babel.template.statement(` @@ -75,19 +76,23 @@ export default ({ `); const assertNotSyntheticPromiseTemplate = babel.template.statement(` - function ANSP_IDENTIFIER(p, s) { + function ANSP_IDENTIFIER(p, s, i = false) { if (p && p[SP_IDENTIFIER]) { throw new CUSTOM_ERROR_BUILDER( 'Result of expression "' + s + '" cannot be used in this context', 'SyntheticPromiseInAlwaysSyncContext'); } + if (i && p && p[SAI_IDENTIFIER]) { + throw new CUSTOM_ERROR_BUILDER( + 'Result of expression "' + s + '" cannot be iterated in this context', + 'SyntheticAsyncIterableInAlwaysSyncContext'); + } return p; } `); const adaptAsyncIterableToSyncIterableTemplate = babel.template.statement(` function AAITSI_IDENTIFIER(original) { - const SAI_IDENTIFIER = Symbol.for("@@mongosh.syntheticAsyncIterable"); if (!original || !original[SAI_IDENTIFIER]) { return { iterable: original, isSyntheticAsyncIterable: false }; } @@ -169,10 +174,6 @@ export default ({ } ); - const assertNotSyntheticExpressionTemplate = babel.template.expression(` - ANSP_IDENTIFIER(NODE, ORIGINAL_SOURCE) - `); - const rethrowTemplate = babel.template.statement(` try { ORIGINAL_CODE; @@ -248,7 +249,7 @@ export default ({ node.start !== undefined ? file.code.slice(node.start ?? undefined, node.end ?? undefined) : '', - 24 + 25 ); if (!wrap) return t.stringLiteral(prettyOriginalString); @@ -349,11 +350,11 @@ export default ({ const commonHelpers = existingIdentifiers ? [] : [ - Object.assign( - syntheticPromiseSymbolTemplate({ - SP_IDENTIFIER: syntheticPromiseSymbol, - }), - { [isGeneratedHelper]: true } + ...syntheticPromiseSymbolTemplate({ + SP_IDENTIFIER: syntheticPromiseSymbol, + SAI_IDENTIFIER: syntheticAsyncIterableSymbol, + }).map((helper) => + Object.assign(helper, { [isGeneratedHelper]: true }) ), Object.assign( expressionHolderVariableTemplate({ @@ -400,6 +401,7 @@ export default ({ assertNotSyntheticPromiseTemplate({ ANSP_IDENTIFIER: assertNotSyntheticPromise, SP_IDENTIFIER: syntheticPromiseSymbol, + SAI_IDENTIFIER: syntheticAsyncIterableSymbol, CUSTOM_ERROR_BUILDER: (this as any).opts.customErrorBuilder ?? t.identifier('Error'), }), @@ -666,17 +668,23 @@ export default ({ if (!functionParent.node.async) { // Transform expression `foo` into `assertNotSyntheticPromise(foo, 'foo')`. + const args = [ + path.node, + getOriginalSourceString(this, path.node, { + wrap: false, + }), + ]; + if ( + (path.parent.type === 'ForOfStatement' && + path.node === path.parent.right) || + (path.parent.type === 'YieldExpression' && path.parent.delegate) + ) { + args.push(t.booleanLiteral(true)); + } path.replaceWith( - Object.assign( - assertNotSyntheticExpressionTemplate({ - ORIGINAL_SOURCE: getOriginalSourceString(this, path.node, { - wrap: false, - }), - NODE: path.node, - ANSP_IDENTIFIER: assertNotSyntheticPromise, - }), - { [isGeneratedHelper]: true } - ) + Object.assign(t.callExpression(assertNotSyntheticPromise, args), { + [isGeneratedHelper]: true, + }) ); return; } @@ -732,7 +740,7 @@ export default ({ }, }, ForOfStatement(path) { - if (path.node.await) return; + if (path.node.await || !path.getFunctionParent()?.node.async) return; if ( path.find( diff --git a/packages/e2e-tests/test/e2e.spec.ts b/packages/e2e-tests/test/e2e.spec.ts index 48c477324..589c9616d 100644 --- a/packages/e2e-tests/test/e2e.spec.ts +++ b/packages/e2e-tests/test/e2e.spec.ts @@ -794,6 +794,19 @@ describe('e2e', function () { const result = await shell.executeLine('out[1]'); expect(result).to.include('i: 1'); }); + + it('works with for-of iteration', async function () { + await shell.executeLine('out = [];'); + const before = + await shell.executeLine(`for (const doc of db.coll.find()) { + print('enter for-of'); + out.push(db.coll.findOne({_id:doc._id})); + print('leave for-of'); + } print('after');`); + expect(before).to.match(/(enter for-of\r?\nleave for-of\r?\n){3}after/); + const result = await shell.executeLine('out[1]'); + expect(result).to.include('i: 1'); + }); }); });