mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-12 18:19:45 -06:00
Reconnect to CDP on WebSocket failure (#6532)
* wip: reconnect to CDP automatically i think we should not do this, see: https://github.com/cypress-io/cypress/issues/5685\#issuecomment-589732584 * reconnect to CDP automatically * cleanup * fix unit tests * update snapshot * replace automation client disconnected line
This commit is contained in:
@@ -43,3 +43,66 @@ Error: connect ECONNREFUSED 127.0.0.1:7777
|
||||
|
||||
|
||||
`
|
||||
|
||||
exports['e2e cdp / handles disconnections as expected'] = `
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Starting)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Cypress: 1.2.3 │
|
||||
│ Browser: FooBrowser 88 │
|
||||
│ Specs: 1 found (spec.ts) │
|
||||
│ Searched: cypress/integration/spec.ts │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Running: spec.ts (1 of 1)
|
||||
|
||||
|
||||
e2e remote debugging disconnect
|
||||
✓ reconnects as expected
|
||||
|
||||
Error: There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.
|
||||
|
||||
connect ECONNREFUSED 127.0.0.1:7777
|
||||
[stack trace lines]
|
||||
|
||||
|
||||
(Results)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Tests: 0 │
|
||||
│ Passing: 0 │
|
||||
│ Failing: 1 │
|
||||
│ Pending: 0 │
|
||||
│ Skipped: 0 │
|
||||
│ Screenshots: 0 │
|
||||
│ Video: true │
|
||||
│ Duration: X seconds │
|
||||
│ Spec Ran: spec.ts │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
(Video)
|
||||
|
||||
- Started processing: Compressing to 32 CRF
|
||||
- Finished processing: /XXX/XXX/XXX/cypress/videos/spec.ts.mp4 (X second)
|
||||
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Finished)
|
||||
|
||||
|
||||
Spec Tests Passing Failing Pending Skipped
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✖ spec.ts XX:XX - - 1 - - │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
✖ 1 of 1 failed (100%) XX:XX - - 1 - -
|
||||
|
||||
|
||||
`
|
||||
|
||||
@@ -240,7 +240,7 @@ const _disableRestorePagesPrompt = function (userDir) {
|
||||
|
||||
// After the browser has been opened, we can connect to
|
||||
// its remote interface via a websocket.
|
||||
const _connectToChromeRemoteInterface = function (port) {
|
||||
const _connectToChromeRemoteInterface = function (port, onError) {
|
||||
// @ts-ignore
|
||||
la(check.userPort(port), 'expected port number to connect CRI to', port)
|
||||
|
||||
@@ -250,7 +250,7 @@ const _connectToChromeRemoteInterface = function (port) {
|
||||
.then((wsUrl) => {
|
||||
debug('received wsUrl %s for port %d', wsUrl, port)
|
||||
|
||||
return CriClient.create(wsUrl)
|
||||
return CriClient.create(wsUrl, onError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ export = {
|
||||
// SECOND connect to the Chrome remote interface
|
||||
// and when the connection is ready
|
||||
// navigate to the actual url
|
||||
const criClient = await this._connectToChromeRemoteInterface(port)
|
||||
const criClient = await this._connectToChromeRemoteInterface(port, options.onError)
|
||||
|
||||
la(criClient, 'expected Chrome remote interface reference', criClient)
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import _ from 'lodash'
|
||||
const chromeRemoteInterface = require('chrome-remote-interface')
|
||||
const errors = require('../errors')
|
||||
|
||||
const debugVerbose = debugModule('cypress-verbose:server:browsers:cri-client')
|
||||
const debug = debugModule('cypress:server:browsers:cri-client')
|
||||
// debug using cypress-verbose:server:browsers:cri-client:send:*
|
||||
const debugVerboseSend = debugModule('cypress-verbose:server:browsers:cri-client:send:[-->]')
|
||||
// debug using cypress-verbose:server:browsers:cri-client:recv:*
|
||||
const debugVerboseReceive = debugModule('cypress-verbose:server:browsers:cri-client:recv:[<--]')
|
||||
|
||||
const WEBSOCKET_NOT_OPEN_RE = /^WebSocket is not open/
|
||||
|
||||
/**
|
||||
* Url returned by the Chrome Remote Interface
|
||||
*/
|
||||
@@ -28,9 +30,8 @@ namespace CRI {
|
||||
'Page.navigate' |
|
||||
'Page.startScreencast'
|
||||
|
||||
export enum EventNames {
|
||||
export type EventName =
|
||||
'Page.screencastFrame'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,7 @@ interface CRIWrapper {
|
||||
/**
|
||||
* Get the `protocolVersion` supported by the browser.
|
||||
*/
|
||||
getProtocolVersion (): Bluebird<string>
|
||||
getProtocolVersion (): Bluebird<Version>
|
||||
/**
|
||||
* Rejects if `protocolVersion` is less than the current version.
|
||||
* @param protocolVersion CDP version string (ex: 1.3)
|
||||
@@ -56,7 +57,7 @@ interface CRIWrapper {
|
||||
* Registers callback for particular event.
|
||||
* @see https://github.com/cyrus-and/chrome-remote-interface#class-cdp
|
||||
*/
|
||||
on (eventName: CRI.EventNames, cb: Function): void
|
||||
on (eventName: CRI.EventName, cb: Function): void
|
||||
/**
|
||||
* Calls underlying remote interface client close
|
||||
*/
|
||||
@@ -128,17 +129,73 @@ const maybeDebugCdpMessages = (cri) => {
|
||||
*/
|
||||
export { chromeRemoteInterface }
|
||||
|
||||
export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIWrapper> => {
|
||||
return chromeRemoteInterface({
|
||||
target: debuggerUrl,
|
||||
local: true,
|
||||
})
|
||||
.then((cri) => {
|
||||
maybeDebugCdpMessages(cri)
|
||||
type DeferredPromise = { resolve: Function, reject: Function }
|
||||
|
||||
cri.send = Bluebird.promisify(cri.send, { context: cri })
|
||||
cri.close = Bluebird.promisify(cri.close, { context: cri })
|
||||
export const create = Bluebird.method((target: websocketUrl, onAsynchronousError: Function): Bluebird<CRIWrapper> => {
|
||||
const subscriptions: {eventName: CRI.EventName, cb: Function}[] = []
|
||||
let enqueuedCommands: {command: CRI.Command, params: any, p: DeferredPromise }[] = []
|
||||
|
||||
let closed = false // has the user called .close on this?
|
||||
let connected = false // is this currently connected to CDP?
|
||||
|
||||
let cri
|
||||
let client: CRIWrapper
|
||||
|
||||
const reconnect = () => {
|
||||
debug('disconnected, attempting to reconnect... %o', { closed })
|
||||
|
||||
connected = false
|
||||
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
return connect()
|
||||
.then(() => {
|
||||
debug('restoring subscriptions + running queued commands... %o', { subscriptions, enqueuedCommands })
|
||||
subscriptions.forEach((sub) => {
|
||||
cri.on(sub.eventName, sub.cb)
|
||||
})
|
||||
|
||||
enqueuedCommands.forEach((cmd) => {
|
||||
cri.send(cmd.command, cmd.params)
|
||||
.then(cmd.p.resolve, cmd.p.reject)
|
||||
})
|
||||
|
||||
enqueuedCommands = []
|
||||
})
|
||||
.catch((err) => {
|
||||
const err2 = new Error(`There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.\n\n${err.message}`)
|
||||
|
||||
onAsynchronousError(err2)
|
||||
})
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
cri?.close()
|
||||
|
||||
debug('connecting %o', { target })
|
||||
|
||||
return chromeRemoteInterface({
|
||||
target,
|
||||
local: true,
|
||||
})
|
||||
.then((newCri) => {
|
||||
cri = newCri
|
||||
connected = true
|
||||
|
||||
maybeDebugCdpMessages(cri)
|
||||
|
||||
cri.send = Bluebird.promisify(cri.send, { context: cri })
|
||||
cri.close = Bluebird.promisify(cri.close, { context: cri })
|
||||
|
||||
// @see https://github.com/cyrus-and/chrome-remote-interface/issues/72
|
||||
cri._notifier.on('disconnect', reconnect)
|
||||
})
|
||||
}
|
||||
|
||||
return connect()
|
||||
.then(() => {
|
||||
const ensureMinimumProtocolVersion = (protocolVersion: string) => {
|
||||
return getProtocolVersion()
|
||||
.then((actual) => {
|
||||
@@ -151,7 +208,7 @@ export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIW
|
||||
}
|
||||
|
||||
const getProtocolVersion = _.memoize(() => {
|
||||
return cri.send('Browser.getVersion')
|
||||
return client.send('Browser.getVersion')
|
||||
// could be any version <= 1.2
|
||||
.catchReturn({ protocolVersion: '0.0' })
|
||||
.then(({ protocolVersion }) => {
|
||||
@@ -159,22 +216,44 @@ export const create = Bluebird.method((debuggerUrl: websocketUrl): Bluebird<CRIW
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Wrapper around Chrome remote interface client
|
||||
* that logs every command sent.
|
||||
*/
|
||||
const client: CRIWrapper = {
|
||||
client = {
|
||||
ensureMinimumProtocolVersion,
|
||||
getProtocolVersion,
|
||||
send: Bluebird.method((command: CRI.Command, params?: object) => {
|
||||
return cri.send(command, params)
|
||||
const enqueue = () => {
|
||||
return new Bluebird((resolve, reject) => {
|
||||
enqueuedCommands.push({ command, params, p: { resolve, reject } })
|
||||
})
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
return cri.send(command, params)
|
||||
.catch((err) => {
|
||||
if (!WEBSOCKET_NOT_OPEN_RE.test(err.message)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
debug('encountered closed websocket on send %o', { command, params, err })
|
||||
|
||||
const p = enqueue()
|
||||
|
||||
reconnect()
|
||||
|
||||
return p
|
||||
})
|
||||
}
|
||||
|
||||
return enqueue()
|
||||
}),
|
||||
on (eventName: CRI.EventNames, cb: Function) {
|
||||
debugVerbose('registering CDP on event %o', { eventName })
|
||||
on (eventName: CRI.EventName, cb: Function) {
|
||||
subscriptions.push({ eventName, cb })
|
||||
debug('registering CDP on event %o', { eventName })
|
||||
|
||||
return cri.on(eventName, cb)
|
||||
},
|
||||
close () {
|
||||
closed = true
|
||||
|
||||
return cri.close()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ const moduleFactory = () => {
|
||||
}
|
||||
}
|
||||
|
||||
options.onError = openProject.options.onError
|
||||
|
||||
relaunchBrowser = () => {
|
||||
debug(
|
||||
'launching browser: %o, spec: %s',
|
||||
|
||||
@@ -24,4 +24,17 @@ describe('e2e cdp', function () {
|
||||
expectedExitCode: 1,
|
||||
snapshot: true,
|
||||
})
|
||||
|
||||
// https://github.com/cypress-io/cypress/issues/5685
|
||||
e2e.it('handles disconnections as expected', {
|
||||
project: Fixtures.projectPath('remote-debugging-disconnect'),
|
||||
spec: 'spec.ts',
|
||||
browser: 'chrome',
|
||||
expectedExitCode: 1,
|
||||
snapshot: true,
|
||||
onStdout: (stdout) => {
|
||||
// the location of this warning is non-deterministic
|
||||
return stdout.replace('The automation client disconnected. Cannot continue running tests.\n', '')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,41 @@
|
||||
describe('e2e remote debugging disconnect', () => {
|
||||
it('reconnects as expected', () => {
|
||||
// 1 probing connection and 1 real connection should have been made during startup
|
||||
cy.task('get:stats')
|
||||
.should('include', {
|
||||
totalConnectionCount: 2,
|
||||
currentConnectionCount: 1,
|
||||
})
|
||||
|
||||
// now, kill all CDP sockets
|
||||
cy.task('kill:active:connections')
|
||||
|
||||
// this will attempt to run a CDP command, realize the socket is dead, enqueue it,
|
||||
// and start the reconnection process
|
||||
cy.wrap(Cypress)
|
||||
// @ts-ignore
|
||||
.invoke('automation', 'remote:debugger:protocol', {
|
||||
command: 'Browser.getVersion',
|
||||
})
|
||||
.should('have.keys', ['protocolVersion', 'product', 'revision', 'userAgent', 'jsVersion'])
|
||||
|
||||
// evidence of a reconnection:
|
||||
cy.task('get:stats')
|
||||
.should('include', {
|
||||
totalConnectionCount: 3,
|
||||
currentConnectionCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('errors if CDP connection cannot be reestablished', () => {
|
||||
cy.task('destroy:server')
|
||||
cy.task('kill:active:connections')
|
||||
|
||||
// this will cause a project-level error once we realize we can't talk to CDP anymore
|
||||
cy.wrap(Cypress)
|
||||
// @ts-ignore
|
||||
.invoke('automation', 'remote:debugger:protocol', {
|
||||
command: 'Browser.getVersion',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable no-console */
|
||||
const la = require('lazy-ass')
|
||||
const net = require('net')
|
||||
|
||||
const realPort = process.env.CYPRESS_REMOTE_DEBUGGING_PORT
|
||||
const fakePort = 17171
|
||||
|
||||
let currentConnectionCount = 0
|
||||
let totalConnectionCount = 0
|
||||
|
||||
let server
|
||||
|
||||
// this is a transparent TCP proxy for Chrome's debugging port
|
||||
// it can kill all existing connections or shut the port down independently of Chrome or Cypress
|
||||
const startTcpProxy = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
server = net.createServer((socket) => {
|
||||
const { remotePort } = socket
|
||||
|
||||
console.error('received connection from ', { remotePort })
|
||||
|
||||
const upstreamSocket = net.connect(fakePort, () => {
|
||||
console.error('hooked to upstream', { remotePort })
|
||||
|
||||
totalConnectionCount++
|
||||
currentConnectionCount++
|
||||
|
||||
server.on('kill-active-connections', () => {
|
||||
console.error('destroying', { remotePort })
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
currentConnectionCount--
|
||||
})
|
||||
|
||||
socket.pipe(upstreamSocket)
|
||||
upstreamSocket.pipe(socket)
|
||||
})
|
||||
|
||||
upstreamSocket.on('error', (err) => {
|
||||
console.error('error on upstream', { remotePort })
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(realPort, resolve)
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = (on) => {
|
||||
on('before:browser:launch', (browser = {}, options) => {
|
||||
la(browser.family === 'chromium', 'this test can only be run with a chromium-family browser')
|
||||
|
||||
// set debugging port to go through our lil TCP proxy
|
||||
const newArgs = options.args.filter((arg) => !arg.startsWith('--remote-debugging-port='))
|
||||
|
||||
newArgs.push(`--remote-debugging-port=${fakePort}`)
|
||||
|
||||
la(newArgs.length === options.args.length, 'arg list length should stay the same length')
|
||||
|
||||
options.args = newArgs
|
||||
|
||||
return startTcpProxy()
|
||||
.then(() => {
|
||||
return options
|
||||
})
|
||||
})
|
||||
|
||||
on('task', {
|
||||
'get:stats' () {
|
||||
return {
|
||||
currentConnectionCount,
|
||||
totalConnectionCount,
|
||||
}
|
||||
},
|
||||
'kill:active:connections' () {
|
||||
server.emit('kill-active-connections')
|
||||
|
||||
return null
|
||||
},
|
||||
'destroy:server' () {
|
||||
console.error('closing server')
|
||||
server.close()
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Bluebird from 'bluebird'
|
||||
import { create } from '../../../lib/browsers/cri-client'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
const { expect, proxyquire, sinon } = require('../../spec_helper')
|
||||
|
||||
@@ -11,22 +12,24 @@ describe('lib/browsers/cri-client', function () {
|
||||
}
|
||||
let send: sinon.SinonStub
|
||||
let criImport: sinon.SinonStub
|
||||
let onError: sinon.SinonStub
|
||||
|
||||
function getClient () {
|
||||
return criClient.create(DEBUGGER_URL)
|
||||
return criClient.create(DEBUGGER_URL, onError)
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
sinon.stub(Bluebird, 'promisify').returnsArg(0)
|
||||
|
||||
send = sinon.stub()
|
||||
onError = sinon.stub()
|
||||
|
||||
criImport = sinon.stub()
|
||||
.withArgs({
|
||||
target: DEBUGGER_URL,
|
||||
local: true,
|
||||
})
|
||||
.resolves({ send })
|
||||
.resolves({ send, _notifier: new EventEmitter() })
|
||||
|
||||
criClient = proxyquire('../lib/browsers/cri-client', {
|
||||
'chrome-remote-interface': criImport,
|
||||
|
||||
@@ -33,6 +33,8 @@ describe('lib/open_project', () => {
|
||||
|
||||
context('#launch', () => {
|
||||
beforeEach(function () {
|
||||
openProject.getProject().options = {}
|
||||
|
||||
this.spec = {
|
||||
absolute: 'path/to/spec',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user