diff --git a/packages/e2e-tests/test/e2e-analytics.spec.ts b/packages/e2e-tests/test/e2e-analytics.spec.ts index e32bf1511..f214bda2d 100644 --- a/packages/e2e-tests/test/e2e-analytics.spec.ts +++ b/packages/e2e-tests/test/e2e-analytics.spec.ts @@ -46,7 +46,7 @@ describe('e2e Analytics Node', function () { ); expect(helloIsWritablePrimary).to.contain('true'); }, - { timeout: 20_000 } + { timeout: 40_000 } ); }); diff --git a/packages/e2e-tests/test/e2e-auth.spec.ts b/packages/e2e-tests/test/e2e-auth.spec.ts index 70c7bc83b..379e37742 100644 --- a/packages/e2e-tests/test/e2e-auth.spec.ts +++ b/packages/e2e-tests/test/e2e-auth.spec.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import type { Db, Document, MongoClientOptions } from 'mongodb'; import { MongoClient } from 'mongodb'; -import { eventually } from '../../../testing/eventually'; import type { TestShell } from './test-shell'; import { skipIfApiStrict, @@ -298,7 +297,7 @@ describe('Auth e2e', function () { it('dropAllUsers', async function () { await shell.executeLine(`use ${dbName}`); shell.writeInputLine('db.dropAllUsers()'); - await eventually(() => { + await shell.eventually(() => { try { shell.assertContainsOutput('{ n: 2, ok: 1 }'); } catch { @@ -508,7 +507,7 @@ describe('Auth e2e', function () { it('dropAllRoles', async function () { await shell.executeLine(`use ${dbName}`); shell.writeInputLine('db.dropAllRoles()'); - await eventually(() => { + await shell.eventually(() => { try { shell.assertContainsOutput('{ n: 2, ok: 1 }'); } catch { @@ -737,7 +736,7 @@ describe('Auth e2e', function () { it('throws if pwd is wrong', async function () { await shell.executeLine(`use ${dbName}`); shell.writeInputLine('db.auth("anna", "pwd2")'); - await eventually( + await shell.eventually( () => { shell.assertContainsError('Authentication failed'); }, @@ -752,7 +751,7 @@ describe('Auth e2e', function () { shell.writeInputLine( 'db.auth({ user: "anna", pwd: "pwd2", mechanism: "not a mechanism"})' ); - await eventually( + await shell.eventually( () => { expect(shell.output).to.match( /MongoParseError: authMechanism one of .+, got not a mechanism/ @@ -1067,7 +1066,7 @@ describe('Auth e2e', function () { 'SCRAM-SHA-1', ], }); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.match( /MongoServerError: Authentication failed|Unable to use SCRAM-SHA-1/ ); @@ -1088,7 +1087,7 @@ describe('Auth e2e', function () { 'SCRAM-SHA-256', ], }); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.match( /MongoServerError: Authentication failed|Unable to use SCRAM-SHA-256/ ); diff --git a/packages/e2e-tests/test/e2e-direct.spec.ts b/packages/e2e-tests/test/e2e-direct.spec.ts index 913cdfc7c..e97096117 100644 --- a/packages/e2e-tests/test/e2e-direct.spec.ts +++ b/packages/e2e-tests/test/e2e-direct.spec.ts @@ -64,12 +64,17 @@ describe('e2e direct connection', function () { `rs.initiate(${JSON.stringify(replSetConfig)})` ); shell.assertContainsOutput('ok: 1'); - await eventually(async () => { - await shell.executeLine('db.isMaster()'); - shell.assertContainsOutput('ismaster: true'); - shell.assertContainsOutput(`me: '${await rs0.hostport()}'`); - shell.assertContainsOutput(`setName: '${replSetId}'`); - }); + await eventually( + async () => { + const output = await shell.executeLine('db.isMaster()'); + expect(output).contains('ismaster: true'); + expect(output).contains(`me: '${await rs0.hostport()}'`); + expect(output).contains(`setName: '${replSetId}'`); + }, + { + timeout: 20_000, + } + ); await shell.executeLine('use admin'); await shell.executeLine( @@ -204,7 +209,7 @@ describe('e2e direct connection', function () { await shell.waitForPrompt(); shell.writeInput('db.testc'); await tabtab(shell); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('db.testcollection'); }); }); @@ -370,7 +375,7 @@ describe('e2e direct connection', function () { await shell.waitForPrompt(); shell.writeInput('db.testc'); await tabtab(shell); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('db.testcollection'); }); }); diff --git a/packages/e2e-tests/test/e2e-editor.spec.ts b/packages/e2e-tests/test/e2e-editor.spec.ts index ac6abb2fb..34563e93a 100644 --- a/packages/e2e-tests/test/e2e-editor.spec.ts +++ b/packages/e2e-tests/test/e2e-editor.spec.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import path from 'path'; import { promises as fs } from 'fs'; -import { eventually } from '../../../testing/eventually'; import { TestShell } from './test-shell'; import { ensureTestShellAfterHook } from './test-shell-context'; import { @@ -73,7 +72,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -97,7 +96,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -118,7 +117,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -145,7 +144,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -163,7 +162,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsError('failed with an exit code 1'); }); }); @@ -182,7 +181,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine('edit'); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(output); }); }); @@ -216,7 +215,7 @@ describe('external editor e2e', function () { "const name = 'I want to test a sequence of writeInputLine'" ); shell.writeInputLine('edit name'); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -254,7 +253,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -279,7 +278,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); @@ -303,7 +302,7 @@ describe('external editor e2e', function () { expect(result).to.include('"editor" has been changed'); shell.writeInputLine(shellOriginalInput); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput(shellModifiedInput); }); }); diff --git a/packages/e2e-tests/test/e2e-snippet.spec.ts b/packages/e2e-tests/test/e2e-snippet.spec.ts index 70b3c1914..a6cc6d85c 100644 --- a/packages/e2e-tests/test/e2e-snippet.spec.ts +++ b/packages/e2e-tests/test/e2e-snippet.spec.ts @@ -47,7 +47,7 @@ describe('snippet integration tests', function () { it('allows managing snippets', async function () { shell.writeInputLine('snippet install analyze-schema'); - await eventually( + await shell.eventually( () => { shell.assertContainsOutput( 'Installed new snippets analyze-schema. Do you want to load them now?' @@ -77,7 +77,7 @@ describe('snippet integration tests', function () { return this.skip(); // https://jira.mongodb.org/browse/MONGOSH-746 } shell.writeInput('snippet insta\t'); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('snippet install'); }); }); diff --git a/packages/e2e-tests/test/e2e.spec.ts b/packages/e2e-tests/test/e2e.spec.ts index dc58693b9..1b4996816 100644 --- a/packages/e2e-tests/test/e2e.spec.ts +++ b/packages/e2e-tests/test/e2e.spec.ts @@ -165,7 +165,7 @@ describe('e2e', function () { shell.assertNoErrors(); await shell.executeLine(',cat,\n'); - await eventually(() => { + await shell.eventually(() => { expect(shell.rawOutput).to.match( /SyntaxError(\x1b\[.*m)+: Unexpected token/ ); @@ -240,7 +240,7 @@ describe('e2e', function () { shell.writeInput('_+55\r\n'); await promisify(setTimeout)(100); shell.writeInput('_+89\r\n'); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('233'); }); }); @@ -565,7 +565,7 @@ describe('e2e', function () { it('runs an unterminated function', async function () { shell.writeInputLine('function x () {'); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('...'); }); shell.assertNoErrors(); @@ -675,7 +675,7 @@ describe('e2e', function () { `load(${JSON.stringify(require.resolve('moment'))})` ); shell.writeInputLine('print("loaded" + "scripts")'); - await eventually( + await shell.eventually( () => { // Use eventually explicitly to get a bigger timeout, lodash is // quite “big” in terms of async rewriting @@ -927,7 +927,7 @@ describe('e2e', function () { forceTerminal: true, }); - await eventually(() => { + await shell.eventually(() => { if (shell.output.includes('Long sleep')) { return; } @@ -936,7 +936,7 @@ describe('e2e', function () { shell.kill('SIGINT'); - await eventually(() => { + await shell.eventually(() => { if (shell.output.includes('MongoshInterruptedError')) { return; } @@ -1039,7 +1039,7 @@ describe('e2e', function () { db.coll1.insertOne({ foo: 89 }); db.coll1.aggregate([{$group: {_id: null, total: {$sum: '$foo'}}}]) `); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('total: 144'); }); }); @@ -1052,7 +1052,7 @@ describe('e2e', function () { db.coll1.insertOne({ foo: 89 }); db.coll1.aggregate([{$group: {_id: null, total: {$sum: '$foo'}}}]) `); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('total: 144'); }); }); @@ -1061,7 +1061,7 @@ describe('e2e', function () { createReadStream( path.resolve(__dirname, 'fixtures', 'exampleplayground.js') ).pipe(shell.process.stdin); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput("{ _id: 'xyz', totalSaleAmount: 150 }"); }); }); @@ -1075,7 +1075,7 @@ describe('e2e', function () { createReadStream( path.resolve(__dirname, 'fixtures', 'asi-script.js') ).pipe(shell.process.stdin); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('admin;system.version;'); }); }); @@ -1154,7 +1154,7 @@ describe('e2e', function () { 'load' ), }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('hello one'); }); // We can't assert the exit code here currently because that breaks @@ -1198,7 +1198,7 @@ describe('e2e', function () { 'load' ), }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('Error: uh oh'); }); expect(await shell.waitForExit()).to.equal(1); @@ -1212,7 +1212,7 @@ describe('e2e', function () { const shell = this.startTestShell({ args: ['--nodb', ...jsContextFlags, '--eval', script], }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('hello one'); }); expect(await shell.waitForExit()).to.equal(0); @@ -1240,7 +1240,7 @@ describe('e2e', function () { 'throw new Error("uh oh")', ], }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('Error: uh oh'); }); expect(await shell.waitForExit()).to.equal(1); @@ -1255,7 +1255,7 @@ describe('e2e', function () { 'setImmediate(() => { throw new Error("uh oh"); })', ], }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('Error: uh oh'); }); expect(await shell.waitForExit()).to.equal( @@ -1272,7 +1272,7 @@ describe('e2e', function () { 'void Promise.resolve().then(() => { throw new Error("uh oh"); })', ], }); - await eventually(() => { + await shell.eventually(() => { shell.assertContainsOutput('Error: uh oh'); }); expect(await shell.waitForExit()).to.equal( @@ -1478,7 +1478,7 @@ describe('e2e', function () { describe('log file', function () { it('creates a log file that keeps track of session events', async function () { expect(await shell.executeLine('print(123 + 456)')).to.include('579'); - await eventually(async () => { + await shell.eventually(async () => { const log = await readLogfile(); expect( log.filter((logEntry) => /Evaluating input/.test(logEntry.msg)) @@ -1514,7 +1514,7 @@ describe('e2e', function () { shell = await startTestShell(); // Arrow up twice to skip the .exit line shell.writeInput('\u001b[A\u001b[A'); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.include('a = 42'); }); shell.writeInput('\n.exit\n'); @@ -1639,7 +1639,7 @@ describe('e2e', function () { it('keeps working when the home directory cannot be created at all', async function () { await fs.writeFile(homedir, 'this is a file and not a directory'); const shell = await startTestShell(); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.include('Warning: Could not access file:'); }); expect(await shell.executeLine('print(123 + 456)')).to.include('579'); @@ -1653,7 +1653,7 @@ describe('e2e', function () { await fs.mkdir(path.dirname(logBasePath), { recursive: true }); await fs.writeFile(logBasePath, 'also not a directory'); const shell = await startTestShell(); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.include('Warning: Could not access file:'); }); expect(await shell.executeLine('print(123 + 456)')).to.include('579'); @@ -1678,7 +1678,7 @@ describe('e2e', function () { await fs.writeFile(configPath, '{}'); await fs.chmod(configPath, 0); // Remove all permissions const shell = await startTestShell(); - await eventually(() => { + await shell.eventually(() => { expect(shell.output).to.include('Warning: Could not access file:'); }); expect(await shell.executeLine('print(123 + 456)')).to.include('579'); diff --git a/packages/e2e-tests/test/test-shell.ts b/packages/e2e-tests/test/test-shell.ts index 66565a1a1..21ad1dce8 100644 --- a/packages/e2e-tests/test/test-shell.ts +++ b/packages/e2e-tests/test/test-shell.ts @@ -9,7 +9,6 @@ import { inspect } from 'util'; import path from 'path'; import stripAnsi from 'strip-ansi'; import { EJSON } from 'bson'; -import { eventually } from '../../../testing/eventually'; /* eslint-disable mocha/no-exports -- This file export hooks wrapping Mocha's Hooks APIs */ @@ -144,6 +143,7 @@ export class TestShell { private _output: string; private _rawOutput: string; private _onClose: Promise; + private _previousOutputLength = 0; constructor( shellProcess: ChildProcessWithoutNullStreams, @@ -165,10 +165,7 @@ export class TestShell { }); } - this._onClose = (async () => { - const [code] = await once(shellProcess, 'close'); - return code; - })(); + this._onClose = once(shellProcess, 'close').then(([code]) => code); } get output(): string { @@ -183,39 +180,19 @@ export class TestShell { return this._process; } - async waitForPrompt(start = 0): Promise { - await eventually(() => { - const output = this._output.slice(start); + /** + * Wait for the last line of the output to become a prompt and resolve with it + */ + async waitForPrompt(start = 0): Promise { + return this.eventually(() => { + const output = this._output.slice(start).trim(); const lines = output.split('\n'); - const found = !!lines - .filter((l) => PROMPT_PATTERN.exec(l)) // a line that is the prompt must at least match the pattern - .find((l) => { - // in some situations the prompt occurs multiple times in the line (but only in tests!) - const prompts = l - .trim() - .replace(/>$/g, '') - .split('>') - .map((m) => m.trim()); - // if there are multiple prompt parts they must all equal - if (prompts.length > 1) { - for (const p of prompts) { - if (p !== prompts[0]) { - return false; - } - } - } - return true; - }); - if (!found) { - throw new assert.AssertionError({ - message: 'expected prompt', - expected: PROMPT_PATTERN.toString(), - actual: - this._output.slice(0, start) + - '[prompt search starts here]' + - output, - }); - } + const lastLine = lines[lines.length - 1]; + assert( + PROMPT_PATTERN.test(lastLine), + `Expected a prompt (last line was "${lastLine}")` + ); + return lastLine; }); } @@ -239,6 +216,64 @@ export class TestShell { ]); } + /** + * Like the `eventually` utility, but instead of calling the callback on a timer, + * the callback is called as output is emitted. + */ + eventually( + cb: () => Promise | T, + { timeout = 10_000 }: { timeout?: number } = {} + ) { + return new Promise((resolve, reject) => { + const { stdout, stderr } = this._process; + let lastError: Error | null = null; + let currentCheck = Promise.resolve(); + + const timeoutTimer = setTimeout(() => { + cleanUp(); + reject( + new Error( + `Timed out (waited ${timeout}ms): ${ + lastError instanceof Error ? lastError.message : 'No cause' + }` + ) + ); + }, timeout); + + function check() { + // Awaits any previous check to ensure there's only one check in-flight + // This is to prevent a new call to the `cb` if a previous call returned a promise which hasn't yet resolved + currentCheck = currentCheck.then(async () => { + try { + const result = await cb(); + cleanUp(); + resolve(result); + } catch (err) { + if (err instanceof Error) { + lastError = err; + } else { + throw new Error( + 'Expected the callback to throw instances of Error' + ); + } + } + }, reject); + } + + function cleanUp() { + stdout.off('data', check); + stderr.off('data', check); + clearTimeout(timeoutTimer); + } + + // Check as the process emits output + stdout.on('data', check); + stderr.on('data', check); + // Check right away + process.nextTick(check); + }); + } + kill(signal?: SignalType): void { this._process.kill(signal); } @@ -249,14 +284,25 @@ export class TestShell { } writeInputLine(chars: string): void { - this.writeInput(`${chars}\n`); + this.writeInput(`${chars.trim()}\n`); } async executeLine(line: string): Promise { - const previousOutputLength = this._output.length; + // Waiting for a prompt to appear since the last execution + await this.waitForPrompt(this._previousOutputLength); + // Keeping an the length of the output to return only output as result of the input + const outputLengthBefore = this._output.length; this.writeInputLine(line); - await this.waitForPrompt(previousOutputLength); - return this._output.slice(previousOutputLength); + // Wait for the execution and a new prompt to appear + const prompt = await this.waitForPrompt(outputLengthBefore); + // Store the output (excluding the following prompt) + const output = this._output.slice( + outputLengthBefore, + this._output.length - prompt.length - 1 + ); + // Storing the output for future executions + this._previousOutputLength = outputLengthBefore + output.length; + return output; } async executeLineWithJSONResult(line: string): Promise {