From d0d1736bdedb5f00cf8fe4ef006b40c484d84bb2 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 19 Jan 2021 19:34:13 +0000 Subject: [PATCH] fix: use stdio for CDP instead of TCP (#14348) --- packages/launcher/lib/browsers.ts | 11 +- packages/server/lib/browsers/cri-client.ts | 132 +++++++++++++----- packages/server/lib/errors.js | 7 + packages/server/package.json | 2 +- .../test/unit/browsers/cri-client_spec.ts | 88 +++++++++--- yarn.lock | 15 +- 6 files changed, 197 insertions(+), 58 deletions(-) diff --git a/packages/launcher/lib/browsers.ts b/packages/launcher/lib/browsers.ts index cd2284ab6796..f6550820927a 100644 --- a/packages/launcher/lib/browsers.ts +++ b/packages/launcher/lib/browsers.ts @@ -107,6 +107,7 @@ export function launch ( browser: FoundBrowser, url: string, args: string[] = [], + opts: { pipeStdio?: boolean } = {}, ) { log('launching browser %o', { browser, url }) @@ -120,7 +121,15 @@ export function launch ( log('spawning browser with args %o', { args }) - const proc = cp.spawn(browser.path, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + const stdio = ['ignore', 'pipe', 'pipe'] + + if (opts.pipeStdio) { + // also pipe stdio 3 and 4 for access to debugger protocol + stdio.push('pipe', 'pipe') + } + + // @ts-ignore + const proc = cp.spawn(browser.path, args, { stdio }) proc.stdout.on('data', (buf) => { log('%s stdout: %s', browser.name, String(buf).trim()) diff --git a/packages/server/lib/browsers/cri-client.ts b/packages/server/lib/browsers/cri-client.ts index 82e7c9fa947c..d9e33b9795b0 100644 --- a/packages/server/lib/browsers/cri-client.ts +++ b/packages/server/lib/browsers/cri-client.ts @@ -1,6 +1,7 @@ import Bluebird from 'bluebird' import debugModule from 'debug' import _ from 'lodash' +import { ChildProcess } from 'child_process' const chromeRemoteInterface = require('chrome-remote-interface') const errors = require('../errors') @@ -85,41 +86,40 @@ const getMajorMinorVersion = (version: string): Version => { const maybeDebugCdpMessages = (cri) => { if (debugVerboseReceive.enabled) { - cri._ws.on('message', (data) => { - data = _ - .chain(JSON.parse(data)) - .tap((data) => { - ([ - 'params.data', // screencast frame data - 'result.data', // screenshot data - ]).forEach((truncatablePath) => { - const str = _.get(data, truncatablePath) - - if (!_.isString(str)) { - return - } + const handleMessage = cri._handleMessage - _.set(data, truncatablePath, _.truncate(str, { - length: 100, - omission: `... [truncated string of total bytes: ${str.length}]`, - })) - }) + cri._handleMessage = (message) => { + const formatted = _.cloneDeep(message) + + ;([ + 'params.data', // screencast frame data + 'result.data', // screenshot data + ]).forEach((truncatablePath) => { + const str = _.get(formatted, truncatablePath) + + if (!_.isString(str)) { + return + } - return data + _.set(formatted, truncatablePath, _.truncate(str, { + length: 100, + omission: `... [truncated string of total bytes: ${str.length}]`, + })) }) - .value() - debugVerboseReceive('received CDP message %o', data) - }) + debugVerboseReceive('received CDP message %o', formatted) + + return handleMessage.call(cri, message) + } } if (debugVerboseSend.enabled) { - const send = cri._ws.send + const send = cri._send - cri._ws.send = (data, callback) => { + cri._send = (data, callback) => { debugVerboseSend('sending CDP command %o', JSON.parse(data)) - return send.call(cri._ws, data, callback) + return send.call(cri, data, callback) } } } @@ -135,17 +135,36 @@ export { chromeRemoteInterface } type DeferredPromise = { resolve: Function, reject: Function } -export const create = Bluebird.method((target: websocketUrl, onAsynchronousError: Function): Bluebird => { +type CreateOpts = { + target?: websocketUrl + process?: ChildProcess +} + +type Message = { + method: CRI.Command + params?: any + sessionId?: string +} + +export const create = Bluebird.method((opts: CreateOpts, onAsynchronousError: Function): Bluebird => { const subscriptions: {eventName: CRI.EventName, cb: Function}[] = [] - let enqueuedCommands: {command: CRI.Command, params: any, p: DeferredPromise }[] = [] + let enqueuedCommands: {message: Message, 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 + let sessionId: string | undefined const reconnect = () => { + if (opts.process) { + // reconnecting doesn't make sense for stdio + onAsynchronousError(errors.get('CDP_STDIO_ERROR')) + + return + } + debug('disconnected, attempting to reconnect... %o', { closed }) connected = false @@ -162,7 +181,7 @@ export const create = Bluebird.method((target: websocketUrl, onAsynchronousError }) enqueuedCommands.forEach((cmd) => { - cri.send(cmd.command, cmd.params) + cri.sendRaw(cmd.message) .then(cmd.p.resolve, cmd.p.reject) }) @@ -176,10 +195,10 @@ export const create = Bluebird.method((target: websocketUrl, onAsynchronousError const connect = () => { cri?.close() - debug('connecting %o', { target }) + debug('connecting %o', opts) return chromeRemoteInterface({ - target, + ...opts, local: true, }) .then((newCri) => { @@ -193,6 +212,46 @@ export const create = Bluebird.method((target: websocketUrl, onAsynchronousError // @see https://github.com/cyrus-and/chrome-remote-interface/issues/72 cri._notifier.on('disconnect', reconnect) + + if (opts.process) { + // if using stdio, we need to find the target before declaring the connection complete + return findTarget() + } + + return + }) + } + + const findTarget = () => { + debug('finding CDP target...') + + return new Bluebird((resolve, reject) => { + const isAboutBlank = (target) => target.type === 'page' && target.url === 'about:blank' + + const attachToTarget = _.once(({ targetId }) => { + debug('attaching to target %o', { targetId }) + cri.send('Target.attachToTarget', { + targetId, + flatten: true, // enable selecting via sessionId + }).then((result) => { + debug('attached to target %o', result) + sessionId = result.sessionId + resolve() + }).catch(reject) + }) + + cri.send('Target.setDiscoverTargets', { discover: true }) + .then(() => { + cri.on('Target.targetCreated', (target) => { + if (isAboutBlank(target)) { + attachToTarget(target) + } + }) + + return cri.send('Target.getTargets') + .then(({ targetInfos }) => targetInfos.filter(isAboutBlank).map(attachToTarget)) + }) + .catch(reject) }) } @@ -222,14 +281,23 @@ export const create = Bluebird.method((target: websocketUrl, onAsynchronousError ensureMinimumProtocolVersion, getProtocolVersion, send: Bluebird.method((command: CRI.Command, params?: object) => { + const message: Message = { + method: command, + params, + } + + if (sessionId) { + message.sessionId = sessionId + } + const enqueue = () => { return new Bluebird((resolve, reject) => { - enqueuedCommands.push({ command, params, p: { resolve, reject } }) + enqueuedCommands.push({ message, params, p: { resolve, reject } }) }) } if (connected) { - return cri.send(command, params) + return cri.sendRaw(message) .catch((err) => { if (!WEBSOCKET_NOT_OPEN_RE.test(err.message)) { throw err diff --git a/packages/server/lib/errors.js b/packages/server/lib/errors.js index 1f99911dd14d..23cb86c9c652 100644 --- a/packages/server/lib/errors.js +++ b/packages/server/lib/errors.js @@ -5,6 +5,7 @@ const chalk = require('chalk') const AU = require('ansi_up') const Promise = require('bluebird') const { stripIndent } = require('./util/strip_indent') +const humanTime = require('./util/human_time') const ansi_up = new AU.default @@ -871,6 +872,12 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) { There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser. ${arg1.stack}` + case 'CDP_STDIO_ERROR': + return 'The connection between Cypress and Chrome has unexpectedly ended. Please restart the browser.' + case 'CDP_STDIO_TIMEOUT': + return `Warning: Cypress failed to connect to ${arg1} via stdio after ${humanTime.long(arg2)}. Falling back to TCP...` + case 'CDP_FALLBACK_SUCCEEDED': + return `Connecting to ${arg1} via TCP was successful, continuing with tests.` case 'CDP_RETRYING_CONNECTION': return `Failed to connect to Chrome, retrying in 1 second (attempt ${chalk.yellow(arg1)}/62)` case 'DEPRECATED_BEFORE_BROWSER_LAUNCH_ARGS': diff --git a/packages/server/package.json b/packages/server/package.json index 1040af85290a..4b82722a3ea7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -39,7 +39,7 @@ "chalk": "2.4.2", "check-more-types": "2.24.0", "chokidar": "3.2.2", - "chrome-remote-interface": "0.28.2", + "chrome-remote-interface": "cypress-io/chrome-remote-interface#147192810f29951cd96c5e406495e9b4d740ba95", "cli-table3": "0.5.1", "coffeescript": "1.12.7", "color-string": "1.5.4", diff --git a/packages/server/test/unit/browsers/cri-client_spec.ts b/packages/server/test/unit/browsers/cri-client_spec.ts index eeb45bcfb520..c584d85b7a4e 100644 --- a/packages/server/test/unit/browsers/cri-client_spec.ts +++ b/packages/server/test/unit/browsers/cri-client_spec.ts @@ -11,32 +11,39 @@ describe('lib/browsers/cri-client', function () { create: typeof create } let send: sinon.SinonStub + let sendRaw: sinon.SinonStub + let criStub: any let criImport: sinon.SinonStub let onError: sinon.SinonStub - let getClient: () => ReturnType + let getClient: (opts?: any) => ReturnType beforeEach(function () { sinon.stub(Bluebird, 'promisify').returnsArg(0) send = sinon.stub() + sendRaw = sinon.stub() onError = sinon.stub() + criStub = { + send, + sendRaw, + on: sinon.stub(), + close: sinon.stub(), + _notifier: new EventEmitter(), + } + criImport = sinon.stub() .withArgs({ target: DEBUGGER_URL, local: true, }) - .resolves({ - send, - close: sinon.stub(), - _notifier: new EventEmitter(), - }) + .resolves(criStub) criClient = proxyquire('../lib/browsers/cri-client', { 'chrome-remote-interface': criImport, }) - getClient = () => criClient.create(DEBUGGER_URL, onError) + getClient = (opts = { target: DEBUGGER_URL }) => criClient.create(opts, onError) }) context('.create', function () { @@ -46,19 +53,65 @@ describe('lib/browsers/cri-client', function () { expect(client.send).to.be.instanceOf(Function) }) + context('with process', function () { + let process: any + + beforeEach(function () { + process = { /** stubbed */} + + criImport.withArgs({ + process, + local: true, + }) + .resolves(criStub) + }) + + it('finds and attaches to target and persists sessionId', async function () { + const target = { + targetId: 'good', + type: 'page', + url: 'about:blank', + } + + const otherTarget = { + targetId: 'bad', + } + + send + .withArgs('Target.setDiscoverTargets').resolves() + .withArgs('Target.getTargets').resolves({ targetInfos: [otherTarget, target] }) + .withArgs('Target.attachToTarget', { targetId: 'good', flatten: true }).resolves({ sessionId: 'session-1' }) + + sendRaw.resolves() + + const client = await getClient({ process }) + + await client.send('Browser.getVersion') + + expect(sendRaw).to.be.calledWith({ + method: 'Browser.getVersion', + params: undefined, + sessionId: 'session-1', + }) + }) + }) + context('#send', function () { - it('calls cri.send with command and data', async function () { - send.resolves() + it('calls cri.sendRaw with command and data', async function () { + sendRaw.resolves() const client = await getClient() client.send('Browser.getVersion', { baz: 'quux' }) - expect(send).to.be.calledWith('Browser.getVersion', { baz: 'quux' }) + expect(sendRaw).to.be.calledWith({ + method: 'Browser.getVersion', + params: { baz: 'quux' }, + }) }) - it('rejects if cri.send rejects', async function () { + it('rejects if cri.sendRaw rejects', async function () { const err = new Error - send.rejects(err) + sendRaw.rejects(err) const client = await getClient() await expect(client.send('Browser.getVersion', { baz: 'quux' })) @@ -74,14 +127,14 @@ describe('lib/browsers/cri-client', function () { it(`with '${msg}'`, async function () { const err = new Error(msg) - send.onFirstCall().rejects(err) - send.onSecondCall().resolves() + sendRaw.onFirstCall().rejects(err) + sendRaw.onSecondCall().resolves() const client = await getClient() await client.send('Browser.getVersion', { baz: 'quux' }) - expect(send).to.be.calledTwice + expect(sendRaw).to.be.calledTwice }) }) }) @@ -90,7 +143,10 @@ describe('lib/browsers/cri-client', function () { context('#ensureMinimumProtocolVersion', function () { function withProtocolVersion (actual, test) { if (actual) { - send.withArgs('Browser.getVersion') + sendRaw.withArgs({ + method: 'Browser.getVersion', + params: undefined, + }) .resolves({ protocolVersion: actual }) } diff --git a/yarn.lock b/yarn.lock index 887b51c40c82..64c2ae0eafc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11060,14 +11060,6 @@ chrome-har-capturer@0.13.4: chrome-remote-interface "^0.25.7" commander "2.x.x" -chrome-remote-interface@0.28.2: - version "0.28.2" - resolved "https://registry.yarnpkg.com/chrome-remote-interface/-/chrome-remote-interface-0.28.2.tgz#6be3554d2c227ff07eb74baa7e5d4911da12a5a6" - integrity sha512-F7mjof7rWvRNsJqhVXuiFU/HWySCxTA9tzpLxUJxVfdLkljwFJ1aMp08AnwXRmmP7r12/doTDOMwaNhFCJsacw== - dependencies: - commander "2.11.x" - ws "^7.2.0" - chrome-remote-interface@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/chrome-remote-interface/-/chrome-remote-interface-0.25.7.tgz#827e85fbef3cc561a9ef2404eb7eee355968c5bc" @@ -11076,6 +11068,13 @@ chrome-remote-interface@^0.25.7: commander "2.11.x" ws "3.3.x" +chrome-remote-interface@cypress-io/chrome-remote-interface#147192810f29951cd96c5e406495e9b4d740ba95: + version "0.28.2" + resolved "https://codeload.github.com/cypress-io/chrome-remote-interface/tar.gz/147192810f29951cd96c5e406495e9b4d740ba95" + dependencies: + commander "2.11.x" + ws "^7.2.0" + chrome-trace-event@^1.0.0, chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4"