Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: move more of video capture into browser automations #23587

Merged
merged 17 commits into from
Aug 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/resolve-dist/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,5 @@ export const getPathToIndex = (pkg: RunnerPkg) => {
}

export const getPathToDesktopIndex = (graphqlPort: number) => {
// For now, if we see that there's a CYPRESS_INTERNAL_VITE_DEV
// we assume we're running Cypress targeting that (dev server)
return `http://localhost:${graphqlPort}/__launchpad/index.html`
}
14 changes: 12 additions & 2 deletions packages/server/lib/browsers/cdp_automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { URL } from 'url'

import type { Automation } from '../automation'
import type { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@packages/proxy'
import type { WriteVideoFrame } from '@packages/types'

export type CdpCommand = keyof ProtocolMapping.Commands

Expand Down Expand Up @@ -168,9 +169,9 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc
return ffToStandardResourceTypeMap[resourceType] || 'other'
}

type SendDebuggerCommand = (message: CdpCommand, data?: any) => Promise<any>
type SendDebuggerCommand = <T extends CdpCommand>(message: T, data?: any) => Promise<ProtocolMapping.Commands[T]['returnType']>
type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise<any> | void
type OnFn = (eventName: CdpEvent, cb: Function) => void
type OnFn = <T extends CdpEvent>(eventName: T, cb: (data: ProtocolMapping.Events[T][0]) => void) => void

// the intersection of what's valid in CDP and what's valid in FFCDP
// Firefox: https://searchfox.org/mozilla-central/rev/98a9257ca2847fad9a19631ac76199474516b31e/remote/cdp/domains/parent/Network.jsm#22
Expand All @@ -188,6 +189,15 @@ export class CdpAutomation {
onFn('Network.responseReceived', this.onResponseReceived)
}

async startVideoRecording (writeVideoFrame: WriteVideoFrame, screencastOpts?) {
this.onFn('Page.screencastFrame', async (e) => {
writeVideoFrame(Buffer.from(e.data, 'base64'))
await this.sendDebuggerCommandFn('Page.screencastFrameAck', { sessionId: e.sessionId })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async function() { await Promise() } is mostly equivalent to function() { return Promise() }. You only need to await things if you want flow control to return to this function after a promise completes (eg, to return a different value than the inner promise resolves to).

Same with the outer startVideoRecording as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you mean, but I prefer it this way, since I don't want to "return" any value. A synchronous function wouldn't return on this line either. Absent of a coding style guide, there's a mix of both ways in the codebase.

})

await this.sendDebuggerCommandFn('Page.startScreencast', screencastOpts)
}

static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, experimentalSessionAndOrigin: boolean): Promise<CdpAutomation> {
const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, sendCloseCommandFn, automation, experimentalSessionAndOrigin)

Expand Down
38 changes: 14 additions & 24 deletions packages/server/lib/browsers/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { BrowserCriClient } from './browser-cri-client'
import type { LaunchedBrowser } from '@packages/launcher/lib/browsers'
import type { CriClient } from './cri-client'
import type { Automation } from '../automation'
import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types'
import type { BrowserLaunchOpts, BrowserNewTabOpts, WriteVideoFrame } from '@packages/types'

const debug = debugModule('cypress:server:browsers:chrome')

Expand Down Expand Up @@ -249,22 +249,10 @@ const _disableRestorePagesPrompt = function (userDir) {
.catch(() => { })
}

const _maybeRecordVideo = async function (client, options, browserMajorVersion) {
if (!options.onScreencastFrame) {
debug('options.onScreencastFrame is false')
async function _recordVideo (cdpAutomation: CdpAutomation, writeVideoFrame: WriteVideoFrame, browserMajorVersion: number) {
const opts = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1)

return client
}

debug('starting screencast')
client.on('Page.screencastFrame', (meta) => {
options.onScreencastFrame(meta)
client.send('Page.screencastFrameAck', { sessionId: meta.sessionId })
})

await client.send('Page.startScreencast', browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1))

return client
await cdpAutomation.startVideoRecording(writeVideoFrame, opts)
flotwig marked this conversation as resolved.
Show resolved Hide resolved
}

// a utility function that navigates to the given URL
Expand Down Expand Up @@ -434,7 +422,9 @@ const _handlePausedRequests = async (client) => {
const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise<void>, options: BrowserLaunchOpts) => {
const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, !!options.experimentalSessionAndOrigin)

return automation.use(cdpAutomation)
automation.use(cdpAutomation)

return cdpAutomation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(for comparison, this is a good use of async/await, because we want to call a method on cdpAutomation after the first promise resolves)

}

export = {
Expand All @@ -448,7 +438,7 @@ export = {

_removeRootExtension,

_maybeRecordVideo,
_recordVideo,

_navigateUsingCRI,

Expand All @@ -468,7 +458,7 @@ export = {
return browserCriClient
},

async _writeExtension (browser: Browser, options) {
async _writeExtension (browser: Browser, options: BrowserLaunchOpts) {
if (browser.isHeadless) {
debug('chrome is running headlessly, not installing extension')

Expand Down Expand Up @@ -565,7 +555,7 @@ export = {
await this.attachListeners(browser, options.url, pageCriClient, automation, options)
},

async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation) {
async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) {
const port = await protocol.getRemoteDebuggingPort()

debug('connecting to existing chrome instance with url and debugging port', { url: options.url, port })
Expand All @@ -580,17 +570,17 @@ export = {
await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options)
},

async attachListeners (browser: Browser, url: string, pageCriClient, automation: Automation, options: BrowserLaunchOpts & { onInitializeNewBrowserTab?: () => void }) {
async attachListeners (browser: Browser, url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) {
if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners')

await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options)
const cdpAutomation = await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options)

await pageCriClient.send('Page.enable')

await options.onInitializeNewBrowserTab?.()
await options['onInitializeNewBrowserTab']?.()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It bypasses type checks on options, otherwise you get: Property 'onInitializeNewBrowserTab' does not exist on type 'BrowserLaunchOpts | BrowserNewTabOpts'. since it's only on one of the two.

Open to suggestions on a cleaner way to do this... await (options as BrowserNewTabOpts).onInitializeNewBrowserTab?.() ??


await Promise.all([
this._maybeRecordVideo(pageCriClient, options, browser.majorVersion),
options.writeVideoFrame && this._recordVideo(cdpAutomation, options.writeVideoFrame, browser.majorVersion),
this._handleDownloads(pageCriClient, options.downloadsFolder, automation),
])

Expand Down
96 changes: 44 additions & 52 deletions packages/server/lib/browsers/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_autom
import * as savedState from '../saved_state'
import utils from './utils'
import * as errors from '../errors'
import type { BrowserInstance } from './types'
import type { Browser, BrowserInstance } from './types'
import type { BrowserWindow, WebContents } from 'electron'
import type { Automation } from '../automation'
import type { BrowserLaunchOpts, Preferences } from '@packages/types'

// TODO: unmix these two types
type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts

const debug = Debug('cypress:server:browsers:electron')
const debugVerbose = Debug('cypress-verbose:server:browsers:electron')
Expand Down Expand Up @@ -68,7 +72,7 @@ const _getAutomation = async function (win, options, parent) {
// after upgrading to Electron 8, CDP screenshots can hang if a screencast is not also running
// workaround: start and stop screencasts between screenshots
// @see https://github.com/cypress-io/cypress/pull/6555#issuecomment-596747134
if (!options.onScreencastFrame) {
if (!options.writeVideoFrame) {
await sendCommand('Page.startScreencast', screencastOpts())
const ret = await fn(message, data)

Expand Down Expand Up @@ -105,37 +109,18 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio
}))
}

const _maybeRecordVideo = async function (webContents, options) {
const { onScreencastFrame } = options

debug('maybe recording video %o', { onScreencastFrame })

if (!onScreencastFrame) {
return
}

webContents.debugger.on('message', (event, method, params) => {
if (method === 'Page.screencastFrame') {
onScreencastFrame(params)
webContents.debugger.sendCommand('Page.screencastFrameAck', { sessionId: params.sessionId })
}
})

await webContents.debugger.sendCommand('Page.startScreencast', screencastOpts())
}

export = {
_defaultOptions (projectRoot, state, options, automation) {
_defaultOptions (projectRoot: string | undefined, state: Preferences, options: BrowserLaunchOpts, automation: Automation): ElectronOpts {
const _this = this

const defaults = {
x: state.browserX,
y: state.browserY,
const defaults: Windows.WindowOptions = {
x: state.browserX || undefined,
y: state.browserY || undefined,
width: state.browserWidth || 1280,
height: state.browserHeight || 720,
devTools: state.isBrowserDevToolsOpen,
minWidth: 100,
minHeight: 100,
devTools: state.isBrowserDevToolsOpen || undefined,
contextMenu: true,
partition: this._getPartition(options),
trackState: {
Expand All @@ -148,8 +133,21 @@ export = {
webPreferences: {
sandbox: true,
},
show: !options.browser.isHeadless,
// prevents a tiny 1px padding around the window
// causing screenshots/videos to be off by 1px
resizable: !options.browser.isHeadless,
onCrashed () {
const err = errors.get('RENDERER_CRASHED')

errors.log(err)

if (!options.onError) throw new Error('Missing onError in onCrashed')

options.onError(err)
},
onFocus () {
if (options.show) {
if (!options.browser.isHeadless) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.show seems clearer to me?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options.show was coming from the run.ts defaultBrowserOptions which was removed in this PR. Here, we only have isHeadless, and I guess we have defaults.show here as well, which is also defined off of isHeadless. I figured it was clearer to use isHeadless again than to use defaults.show (which is actually just isHeadless)

Side note - the data flow and variable naming in this launcher is off the rails. It's due for a major refactor...

return menu.set({ withInternalDevTools: true })
}
},
Expand All @@ -176,18 +174,12 @@ export = {
},
}

if (options.browser.isHeadless) {
// prevents a tiny 1px padding around the window
// causing screenshots/videos to be off by 1px
options.resizable = false
}

return _.defaultsDeep({}, options, defaults)
},

_getAutomation,

async _render (url: string, automation: Automation, preferences, options: { projectRoot: string, isTextTerminal: boolean }) {
async _render (url: string, automation: Automation, preferences, options: { projectRoot?: string, isTextTerminal: boolean }) {
const win = Windows.create(options.projectRoot, preferences)

if (preferences.browser.isHeadless) {
Expand All @@ -212,21 +204,25 @@ export = {

const [parentX, parentY] = parent.getPosition()

options = this._defaultOptions(projectRoot, state, options, automation)
const electronOptions = this._defaultOptions(projectRoot, state, options, automation)

_.extend(options, {
_.extend(electronOptions, {
x: parentX + 100,
y: parentY + 100,
trackState: false,
// in run mode, force new windows to automatically open with show: false
// this prevents window.open inside of javascript client code to cause a new BrowserWindow instance to open
// https://github.com/cypress-io/cypress/issues/123
show: !options.isTextTerminal,
})

const win = Windows.create(projectRoot, options)
const win = Windows.create(projectRoot, electronOptions)

// needed by electron since we prevented default and are creating
// our own BrowserWindow (https://electron.atom.io/docs/api/web-contents/#event-new-window)
e.newGuest = win

return this._launch(win, url, automation, options)
return this._launch(win, url, automation, electronOptions)
},

async _launch (win: BrowserWindow, url: string, automation: Automation, options) {
Expand Down Expand Up @@ -278,7 +274,7 @@ export = {

automation.use(cdpAutomation)
await Promise.all([
_maybeRecordVideo(win.webContents, options),
options.writeVideoFrame && cdpAutomation.startVideoRecording(options.writeVideoFrame),
this._handleDownloads(win, options.downloadsFolder, automation),
])

Expand Down Expand Up @@ -459,30 +455,26 @@ export = {
throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron')
},

async open (browser, url, options, automation) {
const { projectRoot, isTextTerminal } = options

async open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) {
debug('open %o', { browser, url })

const State = await savedState.create(projectRoot, isTextTerminal)
const State = await savedState.create(options.projectRoot, options.isTextTerminal)
const state = await State.get()

debug('received saved state %o', state)

// get our electron default options
// TODO: this is bad, don't mutate the options object
options = this._defaultOptions(projectRoot, state, options, automation)

// get the GUI window defaults now
options = Windows.defaults(options)
const electronOptions: ElectronOpts = Windows.defaults(
this._defaultOptions(options.projectRoot, state, options, automation),
)

debug('browser window options %o', _.omitBy(options, _.isFunction))
debug('browser window options %o', _.omitBy(electronOptions, _.isFunction))

const defaultLaunchOptions = utils.getDefaultLaunchOptions({
preferences: options,
preferences: electronOptions,
})

const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options)
const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, electronOptions)

const { preferences } = launchOptions

Expand All @@ -493,7 +485,7 @@ export = {
isTextTerminal: options.isTextTerminal,
})

await _installExtensions(win, launchOptions.extensions, options)
await _installExtensions(win, launchOptions.extensions, electronOptions)

// cause the webview to receive focus so that
// native browser focus + blur events fire correctly
Expand Down
10 changes: 5 additions & 5 deletions packages/server/lib/browsers/firefox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import _ from 'lodash'
import Bluebird from 'bluebird'
import fs from 'fs-extra'
import Debug from 'debug'
import getPort from 'get-port'
Expand All @@ -21,6 +20,7 @@ import type { BrowserCriClient } from './browser-cri-client'
import type { Automation } from '../automation'
import { getCtx } from '@packages/data-context'
import { getError } from '@packages/errors'
import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types'

const debug = Debug('cypress:server:browsers:firefox')

Expand Down Expand Up @@ -371,15 +371,15 @@ export function _createDetachedInstance (browserInstance: BrowserInstance, brows
return detachedInstance
}

export async function connectToNewSpec (browser: Browser, options: any = {}, automation: Automation) {
export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
await firefoxUtil.connectToNewSpec(options, automation, browserCriClient)
}

export function connectToExisting () {
getCtx().onWarning(getError('UNEXPECTED_INTERNAL_ERROR', new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for firefox')))
}

export async function open (browser: Browser, url, options: any = {}, automation): Promise<BrowserInstance> {
export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise<BrowserInstance> {
// see revision comment here https://wiki.mozilla.org/index.php?title=WebDriver/RemoteProtocol&oldid=1234946
const hasCdp = browser.majorVersion >= 86
const defaultLaunchOptions = utils.getDefaultLaunchOptions({
Expand Down Expand Up @@ -441,7 +441,7 @@ export async function open (browser: Browser, url, options: any = {}, automation
const [
foxdriverPort,
marionettePort,
] = await Bluebird.all([getPort(), getPort()])
] = await Promise.all([getPort(), getPort()])

defaultLaunchOptions.preferences['devtools.debugger.remote-port'] = foxdriverPort
defaultLaunchOptions.preferences['marionette.port'] = marionettePort
Expand All @@ -452,7 +452,7 @@ export async function open (browser: Browser, url, options: any = {}, automation
cacheDir,
extensionDest,
launchOptions,
] = await Bluebird.all([
] = await Promise.all([
utils.ensureCleanCache(browser, options.isTextTerminal),
utils.writeExtension(browser, options.isTextTerminal, options.proxyUrl, options.socketIoRoute),
utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options),
Expand Down
Loading