diff --git a/Gulpfile.js b/Gulpfile.js index ece2f310fb9..e2821c8fba7 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -627,7 +627,7 @@ function testFunctional (fixturesDir, testingEnvironmentName, browserProviderNam .pipe(mocha({ ui: 'bdd', reporter: 'spec', - timeout: typeof v8debug === 'undefined' ? 30000 : Infinity // NOTE: disable timeouts in debug + timeout: typeof v8debug === 'undefined' ? 180000 : Infinity // NOTE: disable timeouts in debug })); } diff --git a/src/browser/connection/index.js b/src/browser/connection/index.js index 07611e1aa21..224d5000b7f 100644 --- a/src/browser/connection/index.js +++ b/src/browser/connection/index.js @@ -12,19 +12,22 @@ import { GeneralError } from '../../errors/runtime'; import MESSAGE from '../../errors/runtime/message'; const IDLE_PAGE_TEMPLATE = read('../../client/browser/idle-page/index.html.mustache'); +const connections = {}; -var connections = {}; export default class BrowserConnection extends EventEmitter { constructor (gateway, browserInfo, permanent) { super(); - this.HEARTBEAT_TIMEOUT = 2 * 60 * 1000; + this.HEARTBEAT_TIMEOUT = 2 * 60 * 1000; + this.BROWSER_RESTART_TIMEOUT = 60 * 1000; this.id = BrowserConnection._generateId(); this.jobQueue = []; this.initScriptsQueue = []; this.browserConnectionGateway = gateway; + this.errorSuppressed = false; + this.testRunAborted = false; this.browserInfo = browserInfo; this.browserInfo.userAgent = ''; @@ -63,33 +66,30 @@ export default class BrowserConnection extends EventEmitter { this.browserConnectionGateway.startServingConnection(this); - this._runBrowser(); + process.nextTick(() => this._runBrowser()); } static _generateId () { return nanoid(7); } - _runBrowser () { - // NOTE: Give caller time to assign event listeners - process.nextTick(async () => { - try { - await this.provider.openBrowser(this.id, this.url, this.browserInfo.browserName); + async _runBrowser () { + try { + await this.provider.openBrowser(this.id, this.url, this.browserInfo.browserName); - if (!this.ready) - await promisifyEvent(this, 'ready'); + if (!this.ready) + await promisifyEvent(this, 'ready'); - this.opened = true; - this.emit('opened'); - } - catch (err) { - this.emit('error', new GeneralError( - MESSAGE.unableToOpenBrowser, - this.browserInfo.providerName + ':' + this.browserInfo.browserName, - err.stack - )); - } - }); + this.opened = true; + this.emit('opened'); + } + catch (err) { + this.emit('error', new GeneralError( + MESSAGE.unableToOpenBrowser, + this.browserInfo.providerName + ':' + this.browserInfo.browserName, + err.stack + )); + } } async _closeBrowser () { @@ -112,14 +112,28 @@ export default class BrowserConnection extends EventEmitter { } } + _createBrowserDisconnectedError () { + return new GeneralError(MESSAGE.browserDisconnected, this.userAgent); + } + _waitForHeartbeat () { this.heartbeatTimeout = setTimeout(() => { - this.emit('error', new GeneralError(MESSAGE.browserDisconnected, this.userAgent)); + const err = this._createBrowserDisconnectedError(); + + this.opened = false; + this.errorSuppressed = false; + this.testRunAborted = true; + + this.emit('disconnected', err); + + if (!this.errorSuppressed) + this.emit('error', err); + }, this.HEARTBEAT_TIMEOUT); } - async _getTestRunUrl (isTestDone) { - if (isTestDone || !this.pendingTestRunUrl) + async _getTestRunUrl (needPopNext) { + if (needPopNext || !this.pendingTestRunUrl) this.pendingTestRunUrl = await this._popNextTestRunUrl(); return this.pendingTestRunUrl; @@ -136,6 +150,43 @@ export default class BrowserConnection extends EventEmitter { return connections[id] || null; } + async restartBrowser () { + this.ready = false; + + this._forceIdle(); + + let resolveTimeout = null; + let isTimeoutExpired = false; + let timeout = null; + + const restartPromise = this._closeBrowser() + .then(() => this._runBrowser()); + + const timeoutPromise = new Promise(resolve => { + resolveTimeout = resolve; + + timeout = setTimeout(() => { + isTimeoutExpired = true; + + resolve(); + }, this.BROWSER_RESTART_TIMEOUT); + }); + + Promise.race([ restartPromise, timeoutPromise ]) + .then(() => { + clearTimeout(timeout); + + if (isTimeoutExpired) + this.emit('error', this._createBrowserDisconnectedError()); + else + resolveTimeout(); + }); + } + + suppressError () { + this.errorSuppressed = true; + } + addWarning (...args) { if (this.currentJob) this.currentJob.warningLog.addWarning(...args); @@ -146,7 +197,7 @@ export default class BrowserConnection extends EventEmitter { } get userAgent () { - var userAgent = this.browserInfo.userAgent; + let userAgent = this.browserInfo.userAgent; if (this.browserInfo.userAgentProviderMetaInfo) userAgent += ` (${this.browserInfo.userAgentProviderMetaInfo})`; @@ -228,13 +279,13 @@ export default class BrowserConnection extends EventEmitter { } getInitScript () { - var initScriptPromise = this.initScriptsQueue[0]; + const initScriptPromise = this.initScriptsQueue[0]; return { code: initScriptPromise ? initScriptPromise.code : null }; } handleInitScriptResult (data) { - var initScriptPromise = this.initScriptsQueue.shift(); + const initScriptPromise = this.initScriptsQueue.shift(); if (initScriptPromise) initScriptPromise.resolve(JSON.parse(data)); @@ -251,7 +302,9 @@ export default class BrowserConnection extends EventEmitter { } if (this.opened) { - var testRunUrl = await this._getTestRunUrl(isTestDone); + const testRunUrl = await this._getTestRunUrl(isTestDone || this.testRunAborted); + + this.testRunAborted = false; if (testRunUrl) { this.idle = false; diff --git a/src/runner/browser-job.js b/src/runner/browser-job.js index 2ce3ad0fc08..ae177f8e51f 100644 --- a/src/runner/browser-job.js +++ b/src/runner/browser-job.js @@ -33,7 +33,7 @@ export default class BrowserJob extends EventEmitter { } _createTestRunController (test, index) { - var testRunController = new TestRunController(test, index + 1, this.proxy, this.screenshots, this.warningLog, + const testRunController = new TestRunController(test, index + 1, this.proxy, this.screenshots, this.warningLog, this.fixtureHookController, this.opts); testRunController.on('test-run-start', () => this.emit('test-run-start', testRunController.testRun)); @@ -100,7 +100,7 @@ export default class BrowserJob extends EventEmitter { if (this.testRunControllerQueue[0].blocked) break; - var testRunController = this.testRunControllerQueue.shift(); + const testRunController = this.testRunControllerQueue.shift(); this._addToCompletionQueue(testRunController); @@ -109,7 +109,7 @@ export default class BrowserJob extends EventEmitter { this.emit('start'); } - var testRunUrl = await testRunController.start(connection); + const testRunUrl = await testRunController.start(connection); if (testRunUrl) return testRunUrl; diff --git a/src/runner/test-run-controller.js b/src/runner/test-run-controller.js index cff54d92843..fd87e1efd06 100644 --- a/src/runner/test-run-controller.js +++ b/src/runner/test-run-controller.js @@ -4,6 +4,7 @@ import TestRun from '../test-run'; import SessionController from '../test-run/session-controller'; const QUARANTINE_THRESHOLD = 3; +const DISCONNECT_THRESHOLD = 3; class Quarantine { constructor () { @@ -38,9 +39,10 @@ export default class TestRunController extends EventEmitter { this.TestRunCtor = TestRunController._getTestRunCtor(test, opts); - this.testRun = null; - this.done = false; - this.quarantine = null; + this.testRun = null; + this.done = false; + this.quarantine = null; + this.disconnectionCount = 0; if (this.opts.quarantineMode) this.quarantine = new Quarantine(); @@ -54,8 +56,8 @@ export default class TestRunController extends EventEmitter { } _createTestRun (connection) { - var screenshotCapturer = this.screenshots.createCapturerFor(this.test, this.index, this.quarantine, connection, this.warningLog); - var TestRunCtor = this.TestRunCtor; + const screenshotCapturer = this.screenshots.createCapturerFor(this.test, this.index, this.quarantine, connection, this.warningLog); + const TestRunCtor = this.TestRunCtor; this.testRun = new TestRunCtor(this.test, connection, screenshotCapturer, this.warningLog, this.opts); @@ -89,6 +91,10 @@ export default class TestRunController extends EventEmitter { } _keepInQuarantine () { + this._restartTest(); + } + + _restartTest () { this.emit('test-run-restart'); } @@ -116,6 +122,18 @@ export default class TestRunController extends EventEmitter { this.emit('test-run-done'); } + async _testRunDisconnected (connection) { + this.disconnectionCount++; + + if (this.disconnectionCount < DISCONNECT_THRESHOLD) { + connection.suppressError(); + + await connection.restartBrowser(); + + this._restartTest(); + } + } + get blocked () { return this.fixtureHookController.isTestBlocked(this.test); } @@ -133,6 +151,7 @@ export default class TestRunController extends EventEmitter { testRun.once('start', () => this.emit('test-run-start')); testRun.once('done', () => this._testRunDone()); + testRun.once('disconnected', () => this._testRunDisconnected(connection)); testRun.start(); diff --git a/src/test-run/index.js b/src/test-run/index.js index dd80d74d683..d94c8bea1e1 100644 --- a/src/test-run/index.js +++ b/src/test-run/index.js @@ -235,7 +235,7 @@ export default class TestRun extends EventEmitter { await fn(this); } catch (err) { - var screenshotPath = null; + let screenshotPath = null; if (this.opts.takeScreenshotsOnFails) screenshotPath = await this.executeCommand(new TakeScreenshotOnFailCommand()); @@ -272,15 +272,25 @@ export default class TestRun extends EventEmitter { this.emit('start'); + const onDisconnected = err => this._disconnect(err); + + this.browserConnection.once('disconnected', onDisconnected); + if (await this._runBeforeHook()) { await this._executeTestFn(PHASE.inTest, this.test.fn); await this._runAfterHook(); } + if (this.disconnected) + return; + + this.browserConnection.removeListener('disconnected', onDisconnected); + if (this.errs.length && this.debugOnFail) await this._enqueueSetBreakpointCommand(null, this.debugReporterPluginHost.formatError(this.errs[0])); await this.executeCommand(new TestDoneCommand()); + this._addPendingPageErrorIfAny(); delete testRunTracker.activeTestRuns[this.session.id]; @@ -309,10 +319,10 @@ export default class TestRun extends EventEmitter { } addError (err, screenshotPath) { - var errList = err instanceof TestCafeErrorList ? err.items : [err]; + const errList = err instanceof TestCafeErrorList ? err.items : [err]; errList.forEach(item => { - var adapter = new TestRunErrorFormattableAdapter(item, { + const adapter = new TestRunErrorFormattableAdapter(item, { userAgent: this.browserConnection.userAgent, screenshotPath: screenshotPath || '', testRunPhase: this.phase @@ -372,7 +382,7 @@ export default class TestRun extends EventEmitter { } _rejectCurrentDriverTask (err) { - err.callsite = err.callsite || this.driverTaskQueue[0].callsite; + err.callsite = err.callsite || this.currentDriverTask.callsite; err.isRejectedDriverTask = true; this.currentDriverTask.reject(err); @@ -415,14 +425,17 @@ export default class TestRun extends EventEmitter { } _handleDriverRequest (driverStatus) { - var pageError = this.pendingPageError || driverStatus.pageError; + const isTestDone = this.currentDriverTask && this.currentDriverTask.command.type === COMMAND_TYPE.testDone; + const pageError = this.pendingPageError || driverStatus.pageError; + const currentTaskRejectedByError = pageError && this._handlePageErrorStatus(pageError); - var currentTaskRejectedByError = pageError && this._handlePageErrorStatus(pageError); + if (this.disconnected) + return new Promise((_, reject) => reject()); this.consoleMessages.concat(driverStatus.consoleMessages); if (!currentTaskRejectedByError && driverStatus.isCommandResult) { - if (this.currentDriverTask.command.type === COMMAND_TYPE.testDone) { + if (isTestDone) { this._resolveCurrentDriverTask(); return TEST_DONE_CONFIRMATION_RESPONSE; @@ -453,7 +466,9 @@ export default class TestRun extends EventEmitter { } async _executeExpression (command) { - var { expression, resultVariableName, isAsyncExpression } = command; + const { resultVariableName, isAsyncExpression } = command; + + let expression = command.expression; if (isAsyncExpression) expression = `await ${expression}`; @@ -464,14 +479,14 @@ export default class TestRun extends EventEmitter { if (isAsyncExpression) expression = `(async () => { return ${expression}; }).apply(this);`; - var result = this._evaluate(expression); + const result = this._evaluate(expression); return isAsyncExpression ? await result : result; } async _executeAssertion (command, callsite) { - var assertionTimeout = command.options.timeout === void 0 ? this.opts.assertionTimeout : command.options.timeout; - var executor = new AssertionExecutor(command, assertionTimeout, callsite); + const assertionTimeout = command.options.timeout === void 0 ? this.opts.assertionTimeout : command.options.timeout; + const executor = new AssertionExecutor(command, assertionTimeout, callsite); executor.once('start-assertion-retries', timeout => this.executeCommand(new ShowAssertionRetriesStatusCommand(timeout))); executor.once('end-assertion-retries', success => this.executeCommand(new HideAssertionRetriesStatusCommand(success))); @@ -561,7 +576,7 @@ export default class TestRun extends EventEmitter { } _rejectCommandWithPageError (callsite) { - var err = this.pendingPageError; + const err = this.pendingPageError; err.callsite = callsite; this.pendingPageError = null; @@ -571,7 +586,7 @@ export default class TestRun extends EventEmitter { // Role management async getStateSnapshot () { - var state = this.session.getStateSnapshot(); + const state = this.session.getStateSnapshot(); state.storages = await this.executeCommand(new BackupStoragesCommand()); @@ -586,26 +601,26 @@ export default class TestRun extends EventEmitter { this.session.useStateSnapshot(null); if (this.activeDialogHandler) { - var removeDialogHandlerCommand = new SetNativeDialogHandlerCommand({ dialogHandler: { fn: null } }); + const removeDialogHandlerCommand = new SetNativeDialogHandlerCommand({ dialogHandler: { fn: null } }); await this.executeCommand(removeDialogHandlerCommand); } if (this.speed !== this.opts.speed) { - var setSpeedCommand = new SetTestSpeedCommand({ speed: this.opts.speed }); + const setSpeedCommand = new SetTestSpeedCommand({ speed: this.opts.speed }); await this.executeCommand(setSpeedCommand); } if (this.pageLoadTimeout !== this.opts.pageLoadTimeout) { - var setPageLoadTimeoutCommand = new SetPageLoadTimeoutCommand({ duration: this.opts.pageLoadTimeout }); + const setPageLoadTimeoutCommand = new SetPageLoadTimeoutCommand({ duration: this.opts.pageLoadTimeout }); await this.executeCommand(setPageLoadTimeoutCommand); } } async _getStateSnapshotFromRole (role) { - var prevPhase = this.phase; + const prevPhase = this.phase; this.phase = PHASE.inRoleInitializer; @@ -629,14 +644,14 @@ export default class TestRun extends EventEmitter { this.disableDebugBreakpoints = true; - var bookmark = new TestRunBookmark(this, role); + const bookmark = new TestRunBookmark(this, role); await bookmark.init(); if (this.currentRoleId) this.usedRoleStates[this.currentRoleId] = await this.getStateSnapshot(); - var stateSnapshot = this.usedRoleStates[role.id] || await this._getStateSnapshotFromRole(role); + const stateSnapshot = this.usedRoleStates[role.id] || await this._getStateSnapshotFromRole(role); this.session.useStateSnapshot(stateSnapshot); @@ -649,20 +664,30 @@ export default class TestRun extends EventEmitter { // Get current URL async getCurrentUrl () { - var builder = new ClientFunctionBuilder(() => { + const builder = new ClientFunctionBuilder(() => { /* eslint-disable no-undef */ return window.location.href; /* eslint-enable no-undef */ }, { boundTestRun: this }); - var getLocation = builder.getFunction(); + const getLocation = builder.getFunction(); return await getLocation(); } + + _disconnect (err) { + this.disconnected = true; + + this._rejectCurrentDriverTask(err); + + this.emit('disconnected', err); + + delete testRunTracker.activeTestRuns[this.session.id]; + } } // Service message handlers -var ServiceMessages = TestRun.prototype; +const ServiceMessages = TestRun.prototype; ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { this.debugLog.driverMessage(msg); @@ -682,7 +707,7 @@ ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { // NOTE: browsers abort an opened xhr request after a certain timeout (the actual duration depends on the browser). // To avoid this, we send an empty response after 2 minutes if we didn't get any command. - var responseTimeout = setTimeout(() => this._resolvePendingRequest(null), MAX_RESPONSE_DELAY); + const responseTimeout = setTimeout(() => this._resolvePendingRequest(null), MAX_RESPONSE_DELAY); return new Promise((resolve, reject) => { this.pendingRequest = { resolve, reject, responseTimeout }; @@ -692,8 +717,8 @@ ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { ServiceMessages[CLIENT_MESSAGES.readyForBrowserManipulation] = async function (msg) { this.debugLog.driverMessage(msg); - var result = null; - var error = null; + let result = null; + let error = null; try { result = await this.browserManipulationQueue.executePendingManipulation(msg); diff --git a/test/functional/fixtures/browser-provider/browser-reconnect/pages/index.html b/test/functional/fixtures/browser-provider/browser-reconnect/pages/index.html new file mode 100644 index 00000000000..25a82555b5b --- /dev/null +++ b/test/functional/fixtures/browser-provider/browser-reconnect/pages/index.html @@ -0,0 +1,9 @@ + + +
+ +