feat: implement webdriver BiDi for Firefox versions 135 and greater (#30870)

* feat: implement BiDi automation client base (without full extension cutover). [run ci]

* chore: implement suggestions from code review. [run ci]

* update error text to be in line with documentation

* address comments from code review

* address comments from code review

* make bidi, cdp, and both active protocols object enumerations to make the code easier to read

* address additional comments from code review

* fix errors from refactor

* update firefox warning
This commit is contained in:
Bill Glesias
2025-02-24 13:17:44 -05:00
committed by GitHub
parent 017750810e
commit 5da0995531
24 changed files with 1479 additions and 241 deletions

View File

@@ -1,3 +1,3 @@
# Bump this version to force CI to re-create the cache from scratch.
2-10-25
2-12-25

View File

@@ -36,7 +36,7 @@ mainBuildFilters: &mainBuildFilters
only:
- develop
- /^release\/\d+\.\d+\.\d+$/
- chore/update_wdio_deps
- feat/implement_bidi
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'update-v8-snapshot-cache-on-develop'
- 'ryanm/chore/add_internal_studio'
@@ -50,7 +50,7 @@ macWorkflowFilters: &darwin-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'ryanm/chore/add_internal_studio', << pipeline.git.branch >> ]
- equal: [ 'feat/implement_bidi', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -61,7 +61,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'ryanm/chore/add_internal_studio', << pipeline.git.branch >> ]
- equal: [ 'feat/implement_bidi', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -84,7 +84,7 @@ windowsWorkflowFilters: &windows-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'ryanm/chore/add_internal_studio', << pipeline.git.branch >> ]
- equal: [ 'feat/implement_bidi', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -160,7 +160,7 @@ commands:
name: Set environment variable to determine whether or not to persist artifacts
command: |
echo "Setting SHOULD_PERSIST_ARTIFACTS variable"
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "ryanm/chore/add_internal_studio" ]]; then
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "feat/implement_bidi" ]]; then
export SHOULD_PERSIST_ARTIFACTS=true
fi' >> "$BASH_ENV"
# You must run `setup_should_persist_artifacts` command and be using bash before running this command
@@ -651,10 +651,19 @@ commands:
description: chrome channel to install
type: string
default: 'stable'
firefox-version:
description: firefox version to install
type: string
default: *firefox-stable-version
inject-document-domain:
description: run subset of tests with injectDocumentDomain config enabled
type: boolean
default: false
is-firefox-cdp:
description: whether or not the group should be associated to the firefox CDP
run or not. This is determined by the browser version.
type: boolean
default: false
steps:
- restore_cached_workspace
@@ -678,6 +687,7 @@ commands:
steps:
- install-browsers:
install-firefox: true
firefox-version: << parameters.firefox-version >>
- when:
condition:
equal: [ webkit, << parameters.browser >> ]
@@ -695,6 +705,9 @@ commands:
if << parameters.inject-document-domain >> ; then
YARN_CMD="cypress:run:inject-document-domain"
PARALLEL="--parallel --group 5x-driver-inject-document-domain-<<parameters.browser>>"
elif << parameters.is-firefox-cdp >> ; then
YARN_CMD="cypress:run"
PARALLEL="--parallel --group 5x-driver-cdp-<<parameters.browser>>"
else
YARN_CMD="cypress:run"
PARALLEL="--parallel --group 5x-driver-<<parameters.browser>>"
@@ -2182,6 +2195,18 @@ jobs:
- run-driver-integration-tests:
browser: firefox
# Runs the driver tests using firefox 134, which does NOT use WebDriver BiDi
# This is to test and make sure there aren't regressions with the old CDP driver
driver-integration-tests-firefox-cdp:
<<: *defaults
resource_class: medium+
parallelism: 5
steps:
- run-driver-integration-tests:
browser: firefox
firefox-version: "134.0.2"
is-firefox-cdp: true
driver-integration-tests-electron:
<<: *defaults
parallelism: 5
@@ -2990,6 +3015,7 @@ linux-x64-workflow: &linux-x64-workflow
- run-webpack-dev-server-integration-tests
- run-vite-dev-server-integration-tests
- driver-integration-tests-firefox
- driver-integration-tests-firefox-cdp
- driver-integration-tests-chrome
- driver-integration-tests-chrome-inject-document-domain
- driver-integration-tests-chrome-beta-inject-document-domain
@@ -3065,6 +3091,10 @@ linux-x64-workflow: &linux-x64-workflow
context: test-runner:cypress-record-key
requires:
- build
- driver-integration-tests-firefox-cdp:
context: test-runner:cypress-record-key
requires:
- build
- driver-integration-tests-electron:
context: test-runner:cypress-record-key
requires:
@@ -3207,6 +3237,7 @@ linux-x64-workflow: &linux-x64-workflow
- linux-lint
- percy-finalize
- driver-integration-tests-firefox
- driver-integration-tests-firefox-cdp
- driver-integration-tests-chrome
- driver-integration-tests-chrome-beta
- driver-integration-tests-chrome-inject-document-domain
@@ -3461,6 +3492,10 @@ linux-x64-contributor-workflow: &linux-x64-contributor-workflow
context: test-runner:cypress-record-key
requires:
- contributor-pr
- driver-integration-tests-firefox-cdp:
context: test-runner:cypress-record-key
requires:
- contributor-pr
- driver-integration-tests-electron:
context: test-runner:cypress-record-key
requires:
@@ -3602,6 +3637,7 @@ linux-x64-contributor-workflow: &linux-x64-contributor-workflow
- linux-lint
- percy-finalize
- driver-integration-tests-firefox
- driver-integration-tests-firefox-cdp
- driver-integration-tests-chrome
- driver-integration-tests-chrome-beta
- driver-integration-tests-electron

View File

@@ -1,8 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 14.0.4
## 14.1.0
_Released 2/25/2025 (PENDING)_
**Features:**
- Firefox versions 135 and up are now automated with [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) instead of [Chrome Devtools Protocol](https://chromedevtools.github.io/devtools-protocol/). Addresses [#30220](https://github.com/cypress-io/cypress/issues/30220).
**Misc:**
- Viewport width, height, and scale now display in a badge above the application under test. The dropdown describing how to set viewport height and width has been removed from the UI. Additionally, component tests now show a notice about URL navigation being disabled in component tests. Addresses [#30999](https://github.com/cypress-io/cypress/issues/30999). Addressed in [#31119](https://github.com/cypress-io/cypress/pull/31119).

View File

@@ -711,6 +711,9 @@ describe('service workers', { defaultCommandTimeout: 1000, pageLoadTimeout: 1000
})
cy.visit('fixtures/service-worker.html')
cy.get('#output').should('have.text', 'done')
cy.get('#output', {
// request takes a little longer with WebDriver BiDi to return (Firefox 135+ only)
timeout: 8000,
}).should('have.text', 'done')
})
})

View File

@@ -1180,6 +1180,9 @@ export const AllCypressErrors = {
CDP_RETRYING_CONNECTION: (attempt: string | number, browserName: string, connectRetryThreshold: number) => {
return errTemplate`Still waiting to connect to ${fmt.off(_.capitalize(browserName))}, retrying in 1 second ${fmt.meta(`(attempt ${attempt}/${connectRetryThreshold})`)}`
},
CDP_FIREFOX_DEPRECATED: () => {
return errTemplate`Since Firefox 129, Chrome DevTools Protocol (CDP) has been deprecated in Firefox. In Firefox 135 and above, Cypress defaults to automating the Firefox browser with WebDriver BiDi. Cypress will no longer support CDP within Firefox in the future and is planned for removal in Cypress 15.`
},
BROWSER_PROCESS_CLOSED_UNEXPECTEDLY: (browserName: string) => {
return errTemplate`\
We detected that the ${fmt.highlight(browserName)} browser process closed unexpectedly.

View File

@@ -1112,6 +1112,11 @@ describe('visual error templates', () => {
default: [1, 'chrome', 62],
}
},
CDP_FIREFOX_DEPRECATED: () => {
return {
default: [],
}
},
BROWSER_PROCESS_CLOSED_UNEXPECTEDLY: () => {
return {
default: ['chrome'],

View File

@@ -31,6 +31,23 @@ const checkIfFirefox = async () => {
return name === 'Firefox'
}
// this check only applies to firefox versioning!
const isBiDiEnabled = async (config) => {
if (!browser || !get(browser, 'runtime.getBrowserInfo') || config.IS_CDP_FORCED_FOR_FIREFOX) {
return false
}
const { version } = await browser.runtime.getBrowserInfo()
if (version) {
const [majorVersion] = version.split('.').map(Number)
return majorVersion >= 135
}
return false
}
const connect = function (host, path, extraOpts) {
const listenToCookieChanges = once(() => {
return browser.cookies.onChanged.addListener((info) => {
@@ -147,10 +164,16 @@ const connect = function (host, path, extraOpts) {
const isFirefox = await checkIfFirefox()
listenToCookieChanges()
// Non-Firefox browsers use CDP for these instead
if (isFirefox) {
// Non-Firefox browsers use CDP for this instead
listenToDownloads()
listenToOnBeforeHeaders()
// if BiDi is enabled, BiDi will handle the network interception.
// Otherwise, CDP does not support it for Firefox and we need to listen for it here.
const isBiDiTurnedOn = await isBiDiEnabled(config)
if (!isBiDiTurnedOn) {
listenToOnBeforeHeaders()
}
}
})

View File

@@ -294,86 +294,116 @@ describe('app/background', () => {
})
context('add header to aut iframe requests', () => {
it('does not add header if it is the top frame', async function () {
const details = {
parentFrameId: -1,
}
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
beforeEach(() => {
browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox', version: '135.0.1' })
})
it('does not add header if it is a nested frame', async function () {
const details = {
parentFrameId: 12345,
}
it('allows for CDP to be used as an escape hatch if BiDi would otherwise be enabled', async function () {
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
await this.connect({
IS_CDP_FORCED_FOR_FIREFOX: true,
})
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
expect(browser.webRequest.onBeforeSendHeaders.addListener).to.be.called
})
it('does not add header if it is a spec frame request', async function () {
const details = {
parentFrameId: 0,
type: 'sub_frame',
url: '/__cypress/integration/spec.js',
}
context('BiDi enabled', () => {
it('does not attach onBeforeSendHeaders listener if BiDi is enabled', async function () {
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
})
it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () {
const details = {
parentFrameId: 0,
type: 'sub_frame',
url: 'http://localhost:3000/index.html',
requestHeaders: [
{ name: 'X-Foo', value: 'Bar' },
],
}
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.deep.equal({
requestHeaders: [
{
name: 'X-Foo',
value: 'Bar',
},
{
name: 'X-Cypress-Is-AUT-Frame',
value: 'true',
},
],
expect(browser.webRequest.onBeforeSendHeaders.addListener).not.to.be.called
})
})
it('does not add before-headers listener if in non-Firefox browser', async function () {
browser.runtime.getBrowserInfo = undefined
context('CDP enabled', () => {
beforeEach(() => {
browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox', version: '134' })
})
const onBeforeSendHeaders = sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
it('does not add header if it is the top frame', async function () {
const details = {
parentFrameId: -1,
}
await this.connect()
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
expect(onBeforeSendHeaders).not.to.be.called
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
})
it('does not add header if it is a nested frame', async function () {
const details = {
parentFrameId: 12345,
}
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
})
it('does not add header if it is a spec frame request', async function () {
const details = {
parentFrameId: 0,
type: 'sub_frame',
url: '/__cypress/integration/spec.js',
}
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.be.undefined
})
it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () {
const details = {
parentFrameId: 0,
type: 'sub_frame',
url: 'http://localhost:3000/index.html',
requestHeaders: [
{ name: 'X-Foo', value: 'Bar' },
],
}
sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details)
expect(result).to.deep.equal({
requestHeaders: [
{
name: 'X-Foo',
value: 'Bar',
},
{
name: 'X-Cypress-Is-AUT-Frame',
value: 'true',
},
],
})
})
it('does not add before-headers listener if in non-Firefox browser', async function () {
browser.runtime.getBrowserInfo = undefined
const onBeforeSendHeaders = sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener')
await this.connect()
expect(onBeforeSendHeaders).not.to.be.called
})
})
})

View File

@@ -1129,6 +1129,7 @@ enum ErrorTypeEnum {
CANNOT_TRASH_ASSETS
CDP_COULD_NOT_CONNECT
CDP_COULD_NOT_RECONNECT
CDP_FIREFOX_DEPRECATED
CDP_RETRYING_CONNECTION
CDP_VERSION_TOO_OLD
CHROME_WEB_SECURITY_NOT_SUPPORTED

View File

@@ -185,6 +185,24 @@ const CalculateCredentialLevelIfApplicable: RequestMiddleware = function () {
this.next()
}
const FormatCookiesIfApplicable: RequestMiddleware = function () {
if (this.req.headers['x-cypress-is-webdriver-bidi'] && this.req.headers.cookie) {
const cookies = this.req.headers.cookie
// in the case of BiDi, cookies come in as foo=bar;bar=baz and not foo=bar; bar=baz,
// i.e. they are delimited differently, which impacts some of our tests and our cookie splicing.
// this regex is to help make sure the cookies are fed in consistently
const bidiStyleCookie = /;\S/gm
if (cookies.match(bidiStyleCookie)) {
this.req.headers.cookie = cookies.replaceAll(';', '; ')
}
}
delete this.req.headers['x-cypress-is-webdriver-bidi']
return this.next()
}
const MaybeAttachCrossOriginCookies: RequestMiddleware = function () {
const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: this.reqMiddlewareSpan, isVerbose })
@@ -560,6 +578,7 @@ export default {
MaybeSimulateSecHeaders,
CorrelateBrowserPreRequest,
CalculateCredentialLevelIfApplicable,
FormatCookiesIfApplicable,
MaybeAttachCrossOriginCookies,
MaybeEndRequestWithBufferedResponse,
SetMatchingRoutes,

View File

@@ -186,12 +186,24 @@ export class PreRequests {
const pendingRequest = this.pendingRequests.shift(key)
if (pendingRequest) {
let cdpLagDuration; let proxyRequestCorrelationDuration = 0
if (browserPreRequest.cdpRequestWillBeSentReceivedTimestamp) {
if (browserPreRequest.cdpRequestWillBeSentTimestamp) {
cdpLagDuration = browserPreRequest.cdpRequestWillBeSentReceivedTimestamp - browserPreRequest.cdpRequestWillBeSentTimestamp
}
if (pendingRequest.proxyRequestReceivedTimestamp) {
proxyRequestCorrelationDuration = Math.max(browserPreRequest.cdpRequestWillBeSentReceivedTimestamp - pendingRequest.proxyRequestReceivedTimestamp, 0)
}
}
const timings = {
cdpRequestWillBeSentTimestamp: browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
cdpRequestWillBeSentTimestamp: browserPreRequest.cdpRequestWillBeSentTimestamp ?? 0,
cdpRequestWillBeSentReceivedTimestamp: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp ?? 0,
proxyRequestReceivedTimestamp: pendingRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp - browserPreRequest.cdpRequestWillBeSentTimestamp,
proxyRequestCorrelationDuration: Math.max(browserPreRequest.cdpRequestWillBeSentReceivedTimestamp - pendingRequest.proxyRequestReceivedTimestamp, 0),
cdpLagDuration,
proxyRequestCorrelationDuration,
}
debugVerbose('Incoming pre-request %s matches pending request. %o', key, browserPreRequest)
@@ -221,8 +233,8 @@ export class PreRequests {
debugVerbose('Caching pre-request %s to be matched later. %o', key, browserPreRequest)
this.pendingPreRequests.push(key, {
browserPreRequest,
cdpRequestWillBeSentTimestamp: browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
cdpRequestWillBeSentTimestamp: browserPreRequest.cdpRequestWillBeSentTimestamp ?? 0,
cdpRequestWillBeSentReceivedTimestamp: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp ?? 0,
})
}

View File

@@ -65,10 +65,10 @@ export type BrowserPreRequest = {
originalResourceType: string | undefined
errorHandled?: boolean
initiator?: Protocol.Network.Initiator
documentURL: string
documentURL?: string
hasRedirectResponse?: boolean
cdpRequestWillBeSentTimestamp: number
cdpRequestWillBeSentReceivedTimestamp: number
cdpRequestWillBeSentTimestamp?: number
cdpRequestWillBeSentReceivedTimestamp?: number
}
export type BrowserPreRequestWithTimings = BrowserPreRequest & ProxyTimings

View File

@@ -33,6 +33,7 @@ describe('http/request-middleware', () => {
'MaybeSimulateSecHeaders',
'CorrelateBrowserPreRequest',
'CalculateCredentialLevelIfApplicable',
'FormatCookiesIfApplicable',
'MaybeAttachCrossOriginCookies',
'MaybeEndRequestWithBufferedResponse',
'SetMatchingRoutes',
@@ -237,6 +238,70 @@ describe('http/request-middleware', () => {
})
})
describe('FormatCookiesIfApplicable', () => {
const { FormatCookiesIfApplicable } = RequestMiddleware
it('does nothing if "x-cypress-is-webdriver-bidi" header is not present', async () => {
const ctx = {
req: {
headers: {
cookie: 'foo=bar;bar=baz;qux=quux',
},
},
res: {
on: (event, listener) => {},
off: (event, listener) => {},
},
}
await testMiddleware([FormatCookiesIfApplicable], ctx)
expect(ctx.req.headers['cookie']).to.equal('foo=bar;bar=baz;qux=quux')
})
describe('header present', () => {
it('does nothing if cookie header is already formatted correctly', async () => {
const ctx = {
req: {
headers: {
'x-cypress-is-webdriver-bidi': true,
cookie: 'foo=bar; bar=baz; qux=quux',
},
},
res: {
on: (event, listener) => {},
off: (event, listener) => {},
},
}
await testMiddleware([FormatCookiesIfApplicable], ctx)
expect(ctx.req.headers['cookie']).to.equal('foo=bar; bar=baz; qux=quux')
expect(ctx.req.headers!['x-cypress-is-webdriver-bidi']).not.to.exist
})
it('delimits cookie headers by "; " if no space exists between cookie values', async () => {
const ctx = {
req: {
headers: {
'x-cypress-is-webdriver-bidi': true,
cookie: 'foo=bar;bar=baz;qux=quux',
},
},
res: {
on: (event, listener) => {},
off: (event, listener) => {},
},
}
await testMiddleware([FormatCookiesIfApplicable], ctx)
expect(ctx.req.headers['cookie']).to.equal('foo=bar; bar=baz; qux=quux')
expect(ctx.req.headers!['x-cypress-is-webdriver-bidi']).not.to.exist
})
})
})
describe('MaybeSimulateSecHeaders', () => {
const { MaybeSimulateSecHeaders } = RequestMiddleware

View File

@@ -0,0 +1,277 @@
import debugModule from 'debug'
import type { Automation } from '../automation'
import type { BrowserPreRequest, BrowserResponseReceived, ResourceType } from '@packages/proxy'
import type { Client as WebDriverClient } from 'webdriver'
import type {
NetworkBeforeRequestSentParameters,
NetworkResponseStartedParameters,
NetworkResponseCompletedParameters,
NetworkFetchErrorParameters,
BrowsingContextInfo,
} from 'webdriver/build/bidi/localTypes'
const debug = debugModule('cypress:server:browsers:bidi_automation')
const debugVerbose = debugModule('cypress-verbose:server:browsers:bidi_automation')
// NOTE: these types will eventually be generated automatically via the 'webdriver' package
// Taken from https://fetch.spec.whatwg.org/#request-initiator-type
type RequestInitiatorType = 'audio' | 'beacon' | 'body' | 'css' | 'early-hints' | 'embed' | 'fetch' | 'font' | 'frame' | 'iframe' | 'image' | 'img' | 'input' | 'link' | 'object' | 'ping' | 'script' | 'track' | 'video' | 'xmlhttprequest' | 'other' | null
// Taken from https://fetch.spec.whatwg.org/#concept-request-destination
type RequestDestination = 'audio' | 'audioworklet' | 'document' | 'embed' | 'font' | 'frame' | 'iframe' | 'image' | 'json' | 'manifest' | 'object' | 'paintworklet' | 'report' | 'script' | 'serviceworker' | 'sharedworker' | 'style' | 'track' | 'video' | 'webidentity' | 'worker' | 'xslt' | ''
export type NetworkBeforeRequestSentParametersModified = NetworkBeforeRequestSentParameters & {
request: {
destination: RequestDestination
initiatorType: RequestInitiatorType
}
}
// maps the network initiator to a ResourceType (which is initially based on CDP).
// This provides us with consistency of types in our request/response middleware, which is important for cy.intercept().
const normalizeResourceType = (type: RequestInitiatorType): ResourceType => {
switch (type) {
case 'css':
return 'stylesheet'
case 'xmlhttprequest':
return 'xhr'
case 'img':
return 'image'
case 'iframe':
return 'document'
// for types we cannot determine, we can set to other.
case 'audio':
case 'beacon':
case 'body':
case 'early-hints':
case 'embed':
case 'frame':
case 'input':
case 'link':
case 'object':
case 'track':
case 'video':
case null:
return 'other'
default:
return type
}
}
export class BidiAutomation {
// events needed to subscribe to in order for our BiDi automation to work properly
static BIDI_EVENTS = [
'network.beforeRequestSent',
'network.responseStarted',
'network.responseCompleted',
'network.fetchError',
'browsingContext.contextCreated',
'browsingContext.contextDestroyed',
]
private webDriverClient: WebDriverClient
private automation: Automation
private autContextId: string | undefined = undefined
// set in firefox-utils when creating the webdriver session initially and in the 'reset:browser:tabs:for:next:spec' automation hook for subsequent tests when the top level context is recreated
private topLevelContextId: string | undefined = undefined
private interceptId: string | undefined = undefined
private constructor (webDriverClient: WebDriverClient, automation: Automation) {
this.automation = automation
this.webDriverClient = webDriverClient
// bind Bidi Events to update the standard automation client
// Error here is expected until webdriver adds initiatorType and destination to the request object
// @ts-expect-error
this.webDriverClient.on('network.beforeRequestSent', this.onBeforeRequestSent)
this.webDriverClient.on('network.responseStarted', this.onResponseStarted)
this.webDriverClient.on('network.responseCompleted', this.onResponseComplete)
this.webDriverClient.on('network.fetchError', this.onFetchError)
this.webDriverClient.on('browsingContext.contextCreated', this.onBrowsingContextCreated)
this.webDriverClient.on('browsingContext.contextDestroyed', this.onBrowsingContextDestroyed)
}
setTopLevelContextId = (contextId?: string) => {
debug(`setting top level context ID to: ${contextId}`)
this.topLevelContextId = contextId
}
private onBrowsingContextCreated = async (params: BrowsingContextInfo) => {
debugVerbose('received browsingContext.contextCreated %o', params)
// the AUT iframe is always the FIRST child created by the top level parent (second is the reporter, if it exists which isnt the case for headless/test replay)
if (!this.autContextId && params.parent && this.topLevelContextId === params.parent) {
debug(`new browsing context ${params.context} created within top-level parent context ${params.parent}.`)
debug(`setting browsing context ${params.context} as the AUT context.`)
this.autContextId = params.context
// in the case of top reloads for setting the url between specs, the AUT context gets destroyed but the top level context still exists.
// in this case, we do NOT have to redefine the top level context intercept but instead update the autContextId to properly identify the
// AUT in the request interceptor.
if (!this.interceptId) {
debugVerbose(`no interceptor defined for top-level context ${params.parent}.`)
debugVerbose(`creating interceptor to determine if a request belongs to the AUT.`)
// BiDi can only intercept top level tab contexts (i.e., not iframes), so the intercept needs to be defined on the top level parent, which is the AUTs
// direct parent in ALL cases. This gets cleaned up in the 'reset:browser:tabs:for:next:spec' automation hook.
// error looks something like: Error: WebDriver Bidi command "network.addIntercept" failed with error: invalid argument - Context with id 123456789 is not a top-level browsing context
const { intercept } = await this.webDriverClient.networkAddIntercept({ phases: ['beforeRequestSent'], contexts: [params.parent] })
debugVerbose(`created network intercept ${intercept} for top-level browsing context ${params.parent}`)
// save a reference to the intercept ID to be cleaned up in the 'reset:browser:tabs:for:next:spec' automation hook.
this.interceptId = intercept
}
}
}
private onBrowsingContextDestroyed = async (params: BrowsingContextInfo) => {
debugVerbose('received browsingContext.contextDestroyed %o', params)
// if the top level context gets destroyed, we need to clear the AUT context and destroy the interceptor as it is no longer applicable
if (params.context === this.topLevelContextId) {
debug(`top level browsing context ${params.context} destroyed`)
// if the top level context is destroyed, we can imply that the AUT context is destroyed along with it
this.autContextId = undefined
this.setTopLevelContextId(undefined)
if (this.interceptId) {
// since we either have:
// 1. a new upper level browser context created above with shouldKeepTabOpen set to true.
// 2. all the previous contexts are destroyed.
// we should clean up our top level interceptor to prevent a memory leak as we no longer need it
await this.webDriverClient.networkRemoveIntercept({
intercept: this.interceptId,
})
debug(`destroyed network intercept ${this.interceptId}`)
this.interceptId = undefined
}
}
// if the AUT context is destroyed (possible that the top level context did not), clear the AUT context Id
if (params.context === this.autContextId) {
debug(`AUT browsing context ${params.context} destroyed within top-level parent context ${params.parent}.`)
this.autContextId = undefined
}
}
private onBeforeRequestSent = async (params: NetworkBeforeRequestSentParametersModified) => {
debugVerbose('received network.beforeRequestSend %o', params)
let url = params.request.url
const parsedHeaders = {}
params.request.headers.forEach((header) => {
parsedHeaders[header.name] = header.value.value
})
const resourceType = normalizeResourceType(params.request.initiatorType)
const browserPreRequest: BrowserPreRequest = {
requestId: params.request.request,
method: params.request.method,
url,
headers: parsedHeaders,
resourceType,
originalResourceType: params.request.initiatorType || params.request.destination,
initiator: params.initiator,
// Since we are NOT using CDP, we set the values to -1 to indicate that we do not have this information.
cdpRequestWillBeSentTimestamp: -1,
cdpRequestWillBeSentReceivedTimestamp: -1,
}
debugVerbose(`prerequest received for request ID ${params.request.request}: %o`, browserPreRequest)
await this.automation.onBrowserPreRequest?.(browserPreRequest)
// since all requests coming from the top level context are blocked, we need to continue them here
// we only want to mutate requests coming from the AUT frame so we can add the X-Cypress-Is-AUT-Frame header
// so the request-middleware can identify the request
if (params.isBlocked) {
params.request.headers.push({
name: 'X-Cypress-Is-WebDriver-BiDi',
value: {
type: 'string',
value: 'true',
},
})
if (params.context === this.autContextId && resourceType === 'document') {
debug(`AUT request detected, adding X-Cypress-Is-AUT-Frame for request ID: ${params.request.request}`)
params.request.headers.push({
name: 'X-Cypress-Is-AUT-Frame',
value: {
type: 'string',
value: 'true',
},
})
}
try {
debug(`continuing request ID: ${params.request.request}`)
await this.webDriverClient.networkContinueRequest({
request: params.request.request,
headers: params.request.headers,
cookies: params.request.cookies,
})
} catch (err: unknown) {
// happens if you kill the Cypress app in the middle of request interception. This error can be ignored
if (!(err as Error)?.message.includes('no such request')) {
throw err
}
}
}
}
private onResponseStarted = (params: NetworkResponseStartedParameters) => {
debugVerbose('received network.responseStarted %o', params)
if (params.response.fromCache) {
this.automation.onRemoveBrowserPreRequest?.(params.request.request)
}
}
private onResponseComplete = (params: NetworkResponseCompletedParameters) => {
debugVerbose('received network.responseComplete %o', params)
if (params.response.fromCache) {
this.automation.onRemoveBrowserPreRequest?.(params.request.request)
return
}
const parsedHeaders = {}
params.response.headers.forEach((header) => {
parsedHeaders[header.name] = header.value.value
})
const browserResponseReceived: BrowserResponseReceived = {
requestId: params.request.request,
status: params.response.status,
headers: parsedHeaders,
}
this.automation.onRequestEvent?.('response:received', browserResponseReceived)
}
private onFetchError = (params: NetworkFetchErrorParameters) => {
debugVerbose('received network.fetchError %o', params)
this.automation.onRemoveBrowserPreRequest?.(params.request.request)
}
close () {
this.webDriverClient.off('network.beforeRequestSent', this.onBeforeRequestSent)
this.webDriverClient.off('network.responseStarted', this.onResponseStarted)
this.webDriverClient.off('network.responseCompleted', this.onResponseComplete)
this.webDriverClient.off('network.fetchError', this.onFetchError)
this.webDriverClient.off('browsingContext.contextCreated', this.onBrowsingContextCreated)
this.webDriverClient.off('browsingContext.contextDestroyed', this.onBrowsingContextDestroyed)
}
static create (webdriverClient: WebDriverClient, automation: Automation) {
return new BidiAutomation(webdriverClient, automation)
}
}

View File

@@ -351,12 +351,12 @@ export = {
// https://chromium.googlesource.com/chromium/src/+/da790f920bbc169a6805a4fb83b4c2ab09532d91
// https://github.com/cypress-io/cypress/issues/1872
if (majorVersion >= CHROME_VERSION_INTRODUCING_PROXY_BYPASS_ON_LOOPBACK) {
if (Number(majorVersion) >= CHROME_VERSION_INTRODUCING_PROXY_BYPASS_ON_LOOPBACK) {
args.push('--proxy-bypass-list=<-loopback>')
}
if (isHeadless) {
if (majorVersion >= CHROME_VERSION_INTRODUCING_HEADLESS_NEW) {
if (Number(majorVersion) >= CHROME_VERSION_INTRODUCING_HEADLESS_NEW) {
args.push('--headless=new')
} else {
args.push('--headless')

View File

@@ -1,5 +1,6 @@
import Debug from 'debug'
import { CdpAutomation } from './cdp_automation'
import { BidiAutomation } from './bidi_automation'
import { BrowserCriClient } from './browser-cri-client'
import type { Client as WebDriverClient } from 'webdriver'
import type { Automation } from '../automation'
@@ -9,7 +10,26 @@ const debug = Debug('cypress:server:browsers:firefox-util')
let webdriverClient: WebDriverClient
async function connectToNewTabClassic () {
async function connectToNewSpecBiDi (options, automation: Automation, browserBiDiClient: BidiAutomation) {
// when connecting to a new spec, we need to re register the existing bidi client to the automation client
// as the automation client resets its middleware between specs in run mode
debug('firefox: reconnecting to blank tab')
const { contexts } = await webdriverClient.browsingContextGetTree({})
browserBiDiClient.setTopLevelContextId(contexts[0].context)
await options.onInitializeNewBrowserTab()
debug(`firefox: navigating to ${options.url}`)
await webdriverClient.browsingContextNavigate({
context: contexts[0].context,
url: options.url,
})
}
async function connectToNewSpecCDP (options, automation: Automation, browserCriClient: BrowserCriClient) {
debug('firefox: reconnecting to blank tab')
// Firefox keeps a blank tab open in versions of Firefox 123 and lower when the last tab is closed.
// For versions 124 and above, a new tab is not created, so @packages/extension creates one for us.
// Since the tab is always available on our behalf,
@@ -19,12 +39,6 @@ async function connectToNewTabClassic () {
await webdriverClient.switchToWindow(handles[0])
await webdriverClient.navigateTo('about:blank')
}
async function connectToNewSpec (options, automation: Automation, browserCriClient: BrowserCriClient) {
debug('firefox: reconnecting to blank tab')
await connectToNewTabClassic()
debug('firefox: reconnecting CDP')
@@ -38,7 +52,16 @@ async function connectToNewSpec (options, automation: Automation, browserCriClie
await options.onInitializeNewBrowserTab()
debug(`firefox: navigating to ${options.url}`)
await navigateToUrlClassic(options.url)
await webdriverClient.navigateTo(options.url)
}
async function setupBiDi (webdriverClient: WebDriverClient, automation: Automation) {
// webdriver needs to subscribe to the correct BiDi events or else the events we are expecting to stream in will not be sent
await webdriverClient.sessionSubscribe({ events: BidiAutomation.BIDI_EVENTS })
const biDiClient = BidiAutomation.create(webdriverClient, automation)
return biDiClient
}
async function setupCDP (remotePort: number, automation: Automation, onError?: (err: Error) => void): Promise<BrowserCriClient> {
@@ -50,10 +73,6 @@ async function setupCDP (remotePort: number, automation: Automation, onError?: (
return browserCriClient
}
async function navigateToUrlClassic (url: string) {
await webdriverClient.navigateTo(url)
}
export default {
async setup ({
automation,
@@ -61,27 +80,47 @@ export default {
url,
remotePort,
webdriverClient: wdInstance,
useWebDriverBiDi,
}: {
automation: Automation
onError?: (err: Error) => void
url: string
remotePort: number
remotePort: number | undefined
webdriverClient: WebDriverClient
}): Promise<BrowserCriClient> {
useWebDriverBiDi: boolean
}): Promise<BrowserCriClient | BidiAutomation> {
// set the WebDriver classic instance instantiated from geckodriver
webdriverClient = wdInstance
const [browserCriClient] = await Promise.all([
setupCDP(remotePort, automation, onError),
])
await navigateToUrlClassic(url)
let client: BrowserCriClient | BidiAutomation
return browserCriClient
if (useWebDriverBiDi) {
client = await setupBiDi(webdriverClient, automation)
// use the BiDi commands to visit the url as opposed to classic webdriver
const { contexts } = await webdriverClient.browsingContextGetTree({})
// at this point there should only be one context: the top level context.
// we need to set this to bind our AUT intercepts correctly. Hopefully we can move this in the future on a more sure implementation
client.setTopLevelContextId(contexts[0].context)
await webdriverClient.browsingContextNavigate({
context: contexts[0].context,
url,
})
} else {
client = await setupCDP(remotePort as number, automation, onError)
// uses webdriver classic to navigate
await webdriverClient.navigateTo(url)
}
return client
},
connectToNewSpec,
connectToNewSpecBiDi,
navigateToUrlClassic,
connectToNewSpecCDP,
setupBiDi,
setupCDP,
}

View File

@@ -14,10 +14,11 @@ import utils from './utils'
import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types'
import os from 'os'
import mimeDb from 'mime-db'
import type { BrowserCriClient } from './browser-cri-client'
import { BrowserCriClient } from './browser-cri-client'
import type { BidiAutomation } from './bidi_automation'
import type { Automation } from '../automation'
import { getCtx } from '@packages/data-context'
import { getError, SerializedError } from '@packages/errors'
import { getError, SerializedError, CypressError } from '@packages/errors'
import type { BrowserLaunchOpts, BrowserNewTabOpts, RunModeVideoApi } from '@packages/types'
import type { RemoteConfig } from 'webdriver'
import type { GeckodriverParameters } from 'geckodriver'
@@ -219,12 +220,6 @@ const defaultPreferences = {
'privacy.trackingprotection.enabled': false,
// CDP is deprecated in Firefox 129 and up.
// In order to enable CDP, we need to set
// remote.active-protocol=2
// @see https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/
// @see https://github.com/cypress-io/cypress/issues/29713
'remote.active-protocols': 2,
// Enable Remote Agent
// https://bugzilla.mozilla.org/show_bug.cgi?id=1544393
'remote.enabled': true,
@@ -340,6 +335,22 @@ const defaultPreferences = {
'browser.helperApps.neverAsk.saveToDisk': downloadMimeTypes,
}
// CDP is deprecated in Firefox 129 and up.
// To enable BiDi (without CDP), we need to set
// remote.active-protocol=1
// In order to enable CDP (without BiDi), we need to set
// remote.active-protocol=2
// both can be enabled via
// remote.active-protocol=3
// @see https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/
// @see https://github.com/cypress-io/cypress/issues/29713
const ACTIVE_PROTOCOLS = Object.freeze({
BIDI: 1,
CDP: 2,
// this key isn't actively used but checked in here if we need to turn it on for internal debugging
CDP_AND_BIDI: 3,
})
const FIREFOX_HEADED_USERCSS = `\
#urlbar:not(.megabar), #urlbar.megabar > #urlbar-background, #searchbar {
background: -moz-Field !important;
@@ -377,6 +388,7 @@ toolbox {
`
let browserCriClient: BrowserCriClient | undefined
let browserBidiClient: BidiAutomation | undefined
/**
* Clear instance state for the chrome instance, this is normally called in on kill or on exit.
@@ -389,10 +401,29 @@ export function clearInstanceState (options: GracefulShutdownOptions = {}) {
browserCriClient.close(options.gracefulShutdown).catch(() => {})
browserCriClient = undefined
}
if (browserBidiClient) {
debug('unbinding bidi client events')
browserBidiClient.close()
browserBidiClient = undefined
}
}
function shouldUseBiDi (browser: Browser): boolean {
try {
// Gating on firefox version 135 to turn on BiDi as this is when all of our internal Cypress tests were able to pass.
return (browser.family === 'firefox' && !process.env.FORCE_FIREFOX_CDP && Number(browser.majorVersion) >= 135)
} catch (err: unknown) {
return false
}
}
export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) {
await firefoxUtil.connectToNewSpec(options, automation, browserCriClient!)
if (shouldUseBiDi(browser)) {
await firefoxUtil.connectToNewSpecBiDi(options, automation, browserBidiClient!)
} else {
await firefoxUtil.connectToNewSpecCDP(options, automation, browserCriClient!)
}
}
export function connectToExisting () {
@@ -410,6 +441,12 @@ async function recordVideo (videoApi: RunModeVideoApi) {
}
export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise<BrowserInstance> {
const USE_WEBDRIVER_BIDI = shouldUseBiDi(browser)
if (!USE_WEBDRIVER_BIDI) {
errors.warning('CDP_FIREFOX_DEPRECATED')
}
const defaultLaunchOptions = utils.getDefaultLaunchOptions({
extensions: [] as string[],
preferences: _.extend({}, defaultPreferences),
@@ -422,6 +459,8 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
],
})
defaultLaunchOptions.preferences['remote.active-protocols'] = USE_WEBDRIVER_BIDI ? ACTIVE_PROTOCOLS.BIDI : ACTIVE_PROTOCOLS.CDP
if (browser.isHeadless) {
defaultLaunchOptions.args.push('-headless')
// we don't need to specify width/height since MOZ_HEADLESS_ env vars will be set
@@ -635,7 +674,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
},
// @see https://firefox-source-docs.mozilla.org/testing/geckodriver/Capabilities.html#moz-debuggeraddress
// we specify the debugger address option for Webdriver, which will return us the CDP address when the capability is returned.
'moz:debuggerAddress': true,
'moz:debuggerAddress': !USE_WEBDRIVER_BIDI,
// @see https://webdriver.io/docs/capabilities/#wdiogeckodriveroptions
// webdriver starts geckodriver with the correct options on behalf of Cypress
'wdio:geckodriverOptions': geckoDriverOptions,
@@ -676,11 +715,11 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
try {
browserReturnStatus = process.kill(browserPID)
} catch (e) {
if (e.code === 'ESRCH') {
} catch (error: unknown) {
if ((error as CypressError)?.code === 'ESRCH') {
debugVerbose('browser process no longer exists. continuing...')
} else {
throw e
throw error
}
}
@@ -690,11 +729,11 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
try {
driverReturnStatus = process.kill(driverPID)
} catch (e) {
if (e.code === 'ESRCH') {
} catch (error: unknown) {
if ((error as CypressError)?.code === 'ESRCH') {
debugVerbose('geckodriver/webdriver process no longer exists. continuing...')
} else {
throw e
throw error
}
}
@@ -705,24 +744,28 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
return browserReturnStatus || driverReturnStatus
}
let cdpPort: number | undefined
if (!USE_WEBDRIVER_BIDI) {
// In some cases, the webdriver session will NOT return the moz:debuggerAddress capability even though
// we set it to true in the capabilities. This is out of our control, so when this happens, we fail the browser
// and gracefully terminate the related processes and attempt to relaunch the browser in the hopes we get a
// CDP address. @see https://github.com/cypress-io/cypress/issues/30352#issuecomment-2405701867 for more details.
if (!webdriverClient.capabilities['moz:debuggerAddress']) {
debug(`firefox failed to spawn with CDP connection. Failing current instance and retrying`)
// since this fails before the instance is created, we need to kill the processes here or else they will stay open
browserInstanceWrapper.kill()
throw new CDPFailedToStartFirefox(`webdriver session failed to start CDP even though "moz:debuggerAddress" was provided. Please try to relaunch the browser`)
if (!webdriverClient.capabilities['moz:debuggerAddress']) {
debugVerbose(`firefox failed to spawn with CDP connection. Failing current instance and retrying`)
// since this fails before the instance is created, we need to kill the processes here or else they will stay open
browserInstanceWrapper.kill()
throw new CDPFailedToStartFirefox(`webdriver session failed to start CDP even though "moz:debuggerAddress" was provided. Please try to relaunch the browser`)
}
cdpPort = parseInt(new URL(`ws://${webdriverClient.capabilities['moz:debuggerAddress']}`).port)
debug(`CDP running on port ${cdpPort}`)
// makes it so get getRemoteDebuggingPort() is calculated correctly
process.env.CYPRESS_REMOTE_DEBUGGING_PORT = cdpPort.toString()
}
const cdpPort = parseInt(new URL(`ws://${webdriverClient.capabilities['moz:debuggerAddress']}`).port)
debug(`CDP running on port ${cdpPort}`)
// makes it so get getRemoteDebuggingPort() is calculated correctly
process.env.CYPRESS_REMOTE_DEBUGGING_PORT = cdpPort.toString()
// install the browser extensions
await Promise.all(_.map(launchOptions.extensions, async (path) => {
debug(`installing extension at path: ${path}`)
@@ -734,13 +777,18 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc
}))
debug('setting up firefox utils')
browserCriClient = await firefoxUtil.setup({ automation, url, webdriverClient, remotePort: cdpPort, onError: options.onError })
const client = await firefoxUtil.setup({ automation, url, webdriverClient, remotePort: cdpPort, useWebDriverBiDi: USE_WEBDRIVER_BIDI, onError: options.onError })
await utils.executeAfterBrowserLaunch(browser, {
webSocketDebuggerUrl: browserCriClient.getWebSocketDebuggerUrl(),
})
} catch (err) {
errors.throwErr('FIREFOX_COULD_NOT_CONNECT', err)
if (client instanceof BrowserCriClient) {
browserCriClient = client
await utils.executeAfterBrowserLaunch(browser, {
webSocketDebuggerUrl: browserCriClient.getWebSocketDebuggerUrl(),
})
} else {
browserBidiClient = client
}
} catch (err: unknown) {
errors.throwErr('FIREFOX_COULD_NOT_CONNECT', err as Error)
}
return browserInstanceWrapper

View File

@@ -4,7 +4,7 @@ import type { Automation } from '../automation'
import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket'
export type Browser = FoundBrowser & {
majorVersion: number
majorVersion: number | string
isHeadless: boolean
isHeaded: boolean
}

View File

@@ -222,7 +222,9 @@ export class SocketBase {
debug('automation:client connected')
// only send the necessary config
automationClient.emit('automation:config', {})
automationClient.emit('automation:config', {
IS_CDP_FORCED_FOR_FIREFOX: !!process.env.FORCE_FIREFOX_CDP,
})
// if our automation disconnects then we're
// in trouble and should probably bomb everything

View File

@@ -0,0 +1,535 @@
import EventEmitter from 'node:events'
import { BidiAutomation } from '../../../lib/browsers/bidi_automation'
import type { Client as WebDriverClient } from 'webdriver'
import type { NetworkBeforeRequestSentParametersModified } from '../../../lib/browsers/bidi_automation'
import type { Automation } from '../../../lib/automation'
import type { NetworkFetchErrorParameters, NetworkResponseCompletedParameters, NetworkResponseStartedParameters } from 'webdriver/build/bidi/localTypes'
// make sure testing promises resolve before asserting on async function conditions
const flushPromises = () => {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 10)
})
}
describe('lib/browsers/bidi_automation', () => {
context('BidiAutomation', () => {
let mockWebdriverClient: WebDriverClient
let mockAutomationClient: Automation
beforeEach(() => {
mockWebdriverClient = new EventEmitter() as WebDriverClient
mockAutomationClient = {
onRequestEvent: sinon.stub(),
onBrowserPreRequest: sinon.stub().resolves(),
onRemoveBrowserPreRequest: sinon.stub().resolves(),
} as unknown as Automation
})
it('binds BIDI_EVENTS when a new instance is created', () => {
mockWebdriverClient.on = sinon.stub()
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
expect(mockWebdriverClient.on).to.have.been.calledWith('network.beforeRequestSent')
expect(mockWebdriverClient.on).to.have.been.calledWith('network.responseStarted')
expect(mockWebdriverClient.on).to.have.been.calledWith('network.responseCompleted')
expect(mockWebdriverClient.on).to.have.been.calledWith('network.fetchError')
expect(mockWebdriverClient.on).to.have.been.calledWith('browsingContext.contextCreated')
expect(mockWebdriverClient.on).to.have.been.calledWith('browsingContext.contextDestroyed')
})
it('unbinds BIDI_EVENTS when close() is called', () => {
mockWebdriverClient.off = sinon.stub()
const bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
bidiAutomationInstance.close()
expect(mockWebdriverClient.off).to.have.been.calledWith('network.beforeRequestSent')
expect(mockWebdriverClient.off).to.have.been.calledWith('network.responseStarted')
expect(mockWebdriverClient.off).to.have.been.calledWith('network.responseCompleted')
expect(mockWebdriverClient.off).to.have.been.calledWith('network.fetchError')
expect(mockWebdriverClient.off).to.have.been.calledWith('browsingContext.contextCreated')
expect(mockWebdriverClient.off).to.have.been.calledWith('browsingContext.contextDestroyed')
})
describe('BrowsingContext', () => {
describe('contextCreated / contextDestroyed', () => {
beforeEach(() => {
mockWebdriverClient.networkAddIntercept = sinon.stub().resolves({ intercept: 'mockInterceptId' })
mockWebdriverClient.networkRemoveIntercept = sinon.stub().resolves()
})
it('does nothing if parent context is not initially assigned', async () => {
const bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockWebdriverClient.emit('browsingContext.contextCreated', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
// @ts-expect-error
expect(bidiAutomationInstance.autContextId).to.be.undefined
// @ts-expect-error
expect(bidiAutomationInstance.interceptId).to.be.undefined
expect(mockWebdriverClient.networkAddIntercept).not.to.have.been.called
mockWebdriverClient.emit('browsingContext.contextDestroyed', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
expect(mockWebdriverClient.networkRemoveIntercept).not.to.have.been.called
})
describe('correctly sets the AUT frame and intercepts requests from the frame when the top frame is set.', () => {
it('Additionally, tears down the AUT when the contexts are destroyed', async () => {
const bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
// manually set the top level context which happens outside the scope of the bidi_automation class
bidiAutomationInstance.setTopLevelContextId('123')
// mock the creation of the AUT context
mockWebdriverClient.emit('browsingContext.contextCreated', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
// @ts-expect-error
expect(bidiAutomationInstance.autContextId).to.equal('456')
// @ts-expect-error
expect(bidiAutomationInstance.interceptId).to.equal('mockInterceptId')
expect(mockWebdriverClient.networkAddIntercept).to.have.been.calledWith({ phases: ['beforeRequestSent'], contexts: ['123'] })
// mock the destruction of the AUT context
mockWebdriverClient.emit('browsingContext.contextDestroyed', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
// @ts-expect-error
expect(bidiAutomationInstance.autContextId).to.equal(undefined)
expect(mockWebdriverClient.networkRemoveIntercept).not.to.have.been.called
// @ts-expect-error
expect(bidiAutomationInstance.topLevelContextId).to.equal('123')
})
it('Additionally, tears down top frame when the contexts are destroyed', async () => {
const bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
// manually set the top level context which happens outside the scope of the bidi_automation class
bidiAutomationInstance.setTopLevelContextId('123')
// mock the creation of the AUT context
mockWebdriverClient.emit('browsingContext.contextCreated', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
// @ts-expect-error
expect(bidiAutomationInstance.autContextId).to.equal('456')
// @ts-expect-error
expect(bidiAutomationInstance.interceptId).to.equal('mockInterceptId')
expect(mockWebdriverClient.networkAddIntercept).to.have.been.calledWith({ phases: ['beforeRequestSent'], contexts: ['123'] })
// Then, mock the destruction of the tab
mockWebdriverClient.emit('browsingContext.contextDestroyed', {
parent: null,
context: '123',
url: 'www.foobar.com',
userContext: '',
children: ['456'],
})
await flushPromises()
expect(mockWebdriverClient.networkRemoveIntercept).to.have.been.calledWith({
intercept: 'mockInterceptId',
})
// @ts-expect-error
expect(bidiAutomationInstance.topLevelContextId).to.be.undefined
// @ts-expect-error
expect(bidiAutomationInstance.interceptId).to.be.undefined
// @ts-expect-error
expect(bidiAutomationInstance.autContextId).to.equal(undefined)
})
})
})
})
describe('Network', () => {
describe('beforeRequestSent', () => {
let mockRequest: NetworkBeforeRequestSentParametersModified
beforeEach(() => {
mockWebdriverClient.networkAddIntercept = sinon.stub().resolves({ intercept: 'mockInterceptId' })
mockWebdriverClient.networkContinueRequest = sinon.stub().resolves()
mockRequest = {
context: '123',
isBlocked: true,
navigation: 'foo',
redirectCount: 0,
request: {
request: 'request1',
url: 'https://www.foobar.com',
method: 'GET',
headers: [
{
name: 'foo',
value: {
type: 'string',
value: 'bar',
},
},
],
cookies: [
{
name: 'baz',
value: {
type: 'string',
value: 'bar',
},
domain: '.foobar.com',
path: '/',
size: 3,
httpOnly: true,
secure: true,
sameSite: 'lax',
expiry: 12345,
},
],
headersSize: 5,
bodySize: 10,
timings: null,
destination: 'script',
initiatorType: 'xmlhttprequest',
},
timestamp: 1234567,
intercepts: ['mockIntercept'],
initiator: {
type: 'preflight',
},
}
})
it('correctly pauses the AUT frame to add the X-Cypress-Is-AUT-Frame header (which is later stripped out in the middleware)', async () => {
const bidiAutomationInstance = BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
// manually set the top level context which happens outside the scope of the bidi_automation class
bidiAutomationInstance.setTopLevelContextId('123')
// mock the creation of the AUT context
mockWebdriverClient.emit('browsingContext.contextCreated', {
parent: '123',
context: '456',
url: 'www.foobar.com',
userContext: '',
children: [],
})
await flushPromises()
mockRequest.request.headers = []
mockRequest.request.cookies = []
mockRequest.context = '456'
mockRequest.request.destination = 'iframe'
mockRequest.request.initiatorType = 'iframe'
mockRequest.initiator.type = 'other'
mockWebdriverClient.emit('network.beforeRequestSent', mockRequest)
await flushPromises()
expect(mockAutomationClient.onBrowserPreRequest).to.have.been.calledWith({
requestId: 'request1',
method: 'GET',
url: 'https://www.foobar.com',
resourceType: 'document',
originalResourceType: 'iframe',
initiator: {
type: 'other',
},
headers: {},
cdpRequestWillBeSentTimestamp: -1,
cdpRequestWillBeSentReceivedTimestamp: -1,
})
expect(mockWebdriverClient.networkContinueRequest).to.have.been.calledWith({
request: 'request1',
headers: [
{
name: 'X-Cypress-Is-WebDriver-BiDi',
value: {
type: 'string',
value: 'true',
},
},
{
name: 'X-Cypress-Is-AUT-Frame',
value: {
type: 'string',
value: 'true',
},
},
],
cookies: [],
})
})
it('correctly calculates the browser pre-request for the middleware', async () => {
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockWebdriverClient.emit('network.beforeRequestSent', mockRequest)
await flushPromises()
expect(mockAutomationClient.onBrowserPreRequest).to.have.been.calledWith({
requestId: 'request1',
method: 'GET',
url: 'https://www.foobar.com',
resourceType: 'xhr',
originalResourceType: 'xmlhttprequest',
initiator: {
type: 'preflight',
},
headers: {
foo: 'bar',
},
cdpRequestWillBeSentTimestamp: -1,
cdpRequestWillBeSentReceivedTimestamp: -1,
})
expect(mockWebdriverClient.networkContinueRequest).to.have.been.calledWith({
request: 'request1',
headers: [
{
name: 'foo',
value: {
type: 'string',
value: 'bar',
},
},
{
name: 'X-Cypress-Is-WebDriver-BiDi',
value: {
type: 'string',
value: 'true',
},
},
],
cookies: [
{
name: 'baz',
value: {
type: 'string',
value: 'bar',
},
domain: '.foobar.com',
path: '/',
size: 3,
httpOnly: true,
secure: true,
sameSite: 'lax',
expiry: 12345,
},
],
})
})
it('swallows "no such request" messages if thrown via killing the Cypress app', () => {
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockWebdriverClient.networkContinueRequest = sinon.stub().throws('no such request')
expect(() => {
mockWebdriverClient.emit('network.beforeRequestSent', mockRequest)
}).not.to.throw()
})
})
describe('responseStarted / responseCompleted', () => {
let mockRequest: NetworkResponseStartedParameters & NetworkResponseCompletedParameters
beforeEach(() => {
mockRequest = {
context: '123',
isBlocked: true,
navigation: 'foo',
redirectCount: 0,
request: {
request: 'request123',
url: 'https://www.foobar.com',
method: 'GET',
headers: [
{
name: 'foo',
value: {
type: 'string',
value: 'bar',
},
},
],
cookies: [
{
name: 'baz',
value: {
type: 'string',
value: 'bar',
},
domain: '.foobar.com',
path: '/',
size: 3,
httpOnly: true,
secure: true,
sameSite: 'lax',
expiry: 12345,
},
],
headersSize: 5,
bodySize: 10,
timings: null,
},
timestamp: 1234567,
intercepts: ['mockIntercept'],
response: {
url: 'https://www.foobar.com',
protocol: 'tcp',
status: 200,
statusText: 'OK',
fromCache: true,
headers: [],
mimeType: 'application/json',
bytesReceived: 47,
headersSize: 6,
bodySize: 20,
content: {
size: 60,
},
},
}
})
const CACHE_EVENTS = ['network.responseStarted', 'network.responseCompleted']
CACHE_EVENTS.forEach((CACHE_EVENT) => {
it(`removes browser pre-request if served from cache (${CACHE_EVENT})`, async () => {
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockWebdriverClient.emit(CACHE_EVENT, mockRequest)
await flushPromises()
expect(mockAutomationClient.onRemoveBrowserPreRequest).to.have.been.calledWith('request123')
})
})
it('calls onRequestEvent "response:received" when a response is completed', async () => {
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockRequest.response.fromCache = false
mockWebdriverClient.emit('network.responseCompleted', mockRequest)
await flushPromises()
expect(mockAutomationClient.onRequestEvent).to.have.been.calledWith('response:received', {
requestId: 'request123',
status: 200,
headers: {},
})
})
})
describe('fetchError', () => {
let mockRequest: NetworkFetchErrorParameters
beforeEach(() => {
mockRequest = {
context: '123',
isBlocked: true,
navigation: 'foo',
redirectCount: 0,
request: {
request: 'request123',
url: 'https://www.foobar.com',
method: 'GET',
headers: [
{
name: 'foo',
value: {
type: 'string',
value: 'bar',
},
},
],
cookies: [
{
name: 'baz',
value: {
type: 'string',
value: 'bar',
},
domain: '.foobar.com',
path: '/',
size: 3,
httpOnly: true,
secure: true,
sameSite: 'lax',
expiry: 12345,
},
],
headersSize: 5,
bodySize: 10,
timings: null,
},
timestamp: 1234567,
intercepts: ['mockIntercept'],
errorText: 'the request could not be completed!',
}
})
it('calls onRemoveBrowserPreRequest when a request errors', async () => {
BidiAutomation.create(mockWebdriverClient, mockAutomationClient)
mockWebdriverClient.emit('network.fetchError', mockRequest)
await flushPromises()
expect(mockAutomationClient.onRemoveBrowserPreRequest).to.have.been.calledWith('request123')
})
})
})
})
})

View File

@@ -12,6 +12,7 @@ import { BrowserCriClient } from '../../../lib/browsers/browser-cri-client'
import { ICriClient } from '../../../lib/browsers/cri-client'
import { type Client as WebDriverClient, default as webdriver } from 'webdriver'
import { EventEmitter } from 'stream'
import { BidiAutomation } from '../../../lib/browsers/bidi_automation'
const path = require('path')
const mockfs = require('mock-fs')
@@ -22,8 +23,10 @@ const specUtil = require('../../specUtils')
describe('lib/browsers/firefox', () => {
const port = 3333
const mockContextId = '1234-5678'
let wdInstance: sinon.SinonStubbedInstance<WebDriverClient>
let browserCriClient: BrowserCriClient
let bidiAutomationClient: BidiAutomation
afterEach(() => {
return mockfs.restore()
@@ -42,18 +45,34 @@ describe('lib/browsers/firefox', () => {
getWindowHandles: sinon.stub(),
switchToWindow: sinon.stub(),
navigateTo: sinon.stub(),
sessionSubscribe: sinon.stub(),
browsingContextGetTree: sinon.stub(),
browsingContextNavigate: sinon.stub(),
capabilities: {
'moz:debuggerAddress': '127.0.0.1:12345',
// @ts-expect-error
'moz:processID': 1234,
'wdio:driverPID': 5678,
},
on: sinon.stub(),
off: sinon.stub(),
}
wdInstance.maximizeWindow.resolves(undefined)
wdInstance.installAddOn.resolves(undefined)
wdInstance.switchToWindow.resolves(undefined)
wdInstance.navigateTo.resolves(undefined)
wdInstance.sessionSubscribe.resolves(undefined)
wdInstance.browsingContextNavigate.resolves(undefined)
wdInstance.browsingContextGetTree.resolves({
contexts: [{
context: mockContextId,
children: null,
url: '',
userContext: mockContextId,
parent: null,
}],
})
sinon.stub(webdriver, 'newSession').resolves(wdInstance)
})
@@ -96,6 +115,11 @@ describe('lib/browsers/firefox', () => {
sinon.stub(BrowserCriClient, 'create').resolves(browserCriClient)
sinon.stub(CdpAutomation, 'create').resolves()
bidiAutomationClient = sinon.createStubInstance(BidiAutomation)
bidiAutomationClient.setTopLevelContextId = sinon.stub().returns(undefined)
sinon.stub(BidiAutomation, 'create').returns(bidiAutomationClient)
})
context('#connectToNewSpec', () => {
@@ -104,7 +128,7 @@ describe('lib/browsers/firefox', () => {
this.options.onInitializeNewBrowserTab = sinon.stub()
})
it('calls connectToNewSpec in firefoxUtil', async function () {
it('CDP: calls connectToNewSpecCDP in firefoxUtil', async function () {
wdInstance.getWindowHandles.resolves(['mock-context-id'])
await firefox.open(this.browser, 'http://', this.options, this.automation)
@@ -120,6 +144,25 @@ describe('lib/browsers/firefox', () => {
// second time when navigating to the spec
expect(wdInstance.navigateTo).to.have.been.calledWith('next-spec-url')
})
it('BiDi: calls connectToNewSpecBiDi in firefoxUtil', async function () {
this.browser.family = 'firefox'
this.browser.majorVersion = '135'
await firefox.open(this.browser, 'http://', this.options, this.automation)
this.options.url = 'next-spec-url'
await firefox.connectToNewSpec(this.browser, this.options, this.automation)
expect(this.options.onInitializeNewBrowserTab).to.have.been.called
expect(wdInstance.browsingContextGetTree).to.have.been.calledWith({})
expect(bidiAutomationClient.setTopLevelContextId).to.have.been.calledWith(mockContextId)
// Only happens one time when navigating to the spec since the context gets created on about:blank, which is tested in BidiAutomation
expect(wdInstance.browsingContextNavigate).to.have.been.calledWith({
context: mockContextId,
url: 'next-spec-url',
})
})
})
it('executes before:browser:launch if registered', async function () {
@@ -202,75 +245,19 @@ describe('lib/browsers/firefox', () => {
}))
})
it('creates the WebDriver session and geckodriver instance through capabilities, installs the extension, and passes the correct port to CDP', async function () {
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith({
logLevel: 'silent',
capabilities: sinon.match({
alwaysMatch: {
browserName: 'firefox',
acceptInsecureCerts: true,
// @see https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions
'moz:firefoxOptions': {
binary: '/path/to/binary',
args: [
'-new-instance',
'-start-debugger-server',
'-no-remote',
...(os.platform() !== 'linux' ? ['-foreground'] : []),
],
// only partially match the preferences object because it is so large
prefs: sinon.match({
'remote.active-protocols': 2,
'remote.enabled': true,
}),
},
'moz:debuggerAddress': true,
'wdio:geckodriverOptions': {
host: '127.0.0.1',
marionetteHost: '127.0.0.1',
marionettePort: sinon.match(Number),
websocketPort: sinon.match(Number),
profileRoot: '/path/to/appData/firefox-stable/interactive',
binaryPath: undefined,
spawnOpts: sinon.match({
stdio: ['ignore', 'pipe', 'pipe'],
env: {
MOZ_REMOTE_SETTINGS_DEVTOOLS: '1',
MOZ_HEADLESS_WIDTH: '1280',
MOZ_HEADLESS_HEIGHT: '720',
},
}),
jsdebugger: false,
log: 'error',
logNoTruncate: false,
},
},
firstMatch: [],
}),
})
expect(wdInstance.installAddOn).to.have.been.calledWith('/path/to/ext', true)
expect(wdInstance.navigateTo).to.have.been.calledWith('http://')
// make sure CDP gets the expected port
expect(BrowserCriClient.create).to.be.calledWith({ hosts: ['127.0.0.1', '::1'], port: 12345, browserName: 'Firefox', onAsynchronousError: undefined, onServiceWorkerClientEvent: undefined })
})
describe('debugging', () => {
afterEach(() => {
debug.disable()
})
it('sets additional arguments if "DEBUG=cypress-verbose:server:browsers:geckodriver" and "DEBUG=cypress-verbose:server:browsers:webdriver" is set', async function () {
debug.enable('cypress-verbose:server:browsers:geckodriver,cypress-verbose:server:browsers:webdriver')
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith({
logLevel: 'info',
describe(`webdriver capabilities`, () => {
const getExpectedCapabilities = ({
shouldUseBiDi,
isDebugEnabled,
}: {
shouldUseBiDi?: boolean
isDebugEnabled?: boolean
} = {
shouldUseBiDi: false,
isDebugEnabled: false,
}) => {
return {
logLevel: isDebugEnabled ? 'info' : 'silent',
capabilities: sinon.match({
alwaysMatch: {
browserName: 'firefox',
@@ -285,12 +272,12 @@ describe('lib/browsers/firefox', () => {
...(os.platform() !== 'linux' ? ['-foreground'] : []),
],
// only partially match the preferences object because it is so large
prefs: sinon.match({
'remote.active-protocols': 2,
prefs: {
'remote.active-protocols': shouldUseBiDi ? 1 : 2,
'remote.enabled': true,
}),
},
},
'moz:debuggerAddress': true,
'moz:debuggerAddress': !shouldUseBiDi,
'wdio:geckodriverOptions': {
host: '127.0.0.1',
marionetteHost: '127.0.0.1',
@@ -298,29 +285,89 @@ describe('lib/browsers/firefox', () => {
websocketPort: sinon.match(Number),
profileRoot: '/path/to/appData/firefox-stable/interactive',
binaryPath: undefined,
spawnOpts: sinon.match({
spawnOpts: {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
MOZ_REMOTE_SETTINGS_DEVTOOLS: '1',
MOZ_HEADLESS_WIDTH: '1280',
MOZ_HEADLESS_HEIGHT: '720',
},
}),
jsdebugger: true,
log: 'debug',
logNoTruncate: true,
},
jsdebugger: !!isDebugEnabled,
log: isDebugEnabled ? 'debug' : 'error',
logNoTruncate: !!isDebugEnabled,
},
},
firstMatch: [],
}),
}
}
describe(`creates the WebDriver session and geckodriver instance through capabilities, installs the extension, and passes the correct port to CDP`, function () {
it('for CDP', async function () {
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith((getExpectedCapabilities({ shouldUseBiDi: false })))
expect(wdInstance.installAddOn).to.have.been.calledWith('/path/to/ext', true)
expect(wdInstance.navigateTo).to.have.been.calledWith('http://')
// make sure CDP gets the expected port
expect(BrowserCriClient.create).to.be.calledWith({ hosts: ['127.0.0.1', '::1'], port: 12345, browserName: 'Firefox', onAsynchronousError: undefined, onServiceWorkerClientEvent: undefined })
})
expect(wdInstance.installAddOn).to.have.been.calledWith('/path/to/ext', true)
it('for BiDi', async function () {
this.browser.family = 'firefox'
this.browser.majorVersion = '135'
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith((getExpectedCapabilities({ shouldUseBiDi: true })))
expect(wdInstance.navigateTo).to.have.been.calledWith('http://')
expect(wdInstance.installAddOn).to.have.been.calledWith('/path/to/ext', true)
// make sure CDP gets the expected port
expect(BrowserCriClient.create).to.be.calledWith({ hosts: ['127.0.0.1', '::1'], port: 12345, browserName: 'Firefox', onAsynchronousError: undefined, onServiceWorkerClientEvent: undefined })
expect(wdInstance.sessionSubscribe).to.be.calledWith({ events: [
'network.beforeRequestSent',
'network.responseStarted',
'network.responseCompleted',
'network.fetchError',
'browsingContext.contextCreated',
'browsingContext.contextDestroyed',
] })
expect(wdInstance.browsingContextGetTree).to.be.calledWith({})
expect(wdInstance.browsingContextNavigate).to.have.been.calledWith({
context: mockContextId,
url: 'http://',
})
// make sure Bidi gets created
expect(BidiAutomation.create).to.be.calledWith(wdInstance, this.automation)
expect(bidiAutomationClient.setTopLevelContextId).to.be.calledWith(mockContextId)
})
})
describe('debugging: sets additional arguments if "DEBUG=cypress-verbose:server:browsers:geckodriver" and "DEBUG=cypress-verbose:server:browsers:webdriver" is set', () => {
afterEach(() => {
debug.disable()
})
it('for CDP', async function () {
debug.enable('cypress-verbose:server:browsers:geckodriver,cypress-verbose:server:browsers:webdriver')
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith((getExpectedCapabilities({ isDebugEnabled: true })))
})
it('for BiDi', async function () {
this.browser.family = 'firefox'
this.browser.majorVersion = '135'
debug.enable('cypress-verbose:server:browsers:geckodriver,cypress-verbose:server:browsers:webdriver')
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(webdriver.newSession).to.have.been.calledWith((getExpectedCapabilities({ isDebugEnabled: true, shouldUseBiDi: true })))
})
})
})
@@ -444,21 +491,37 @@ describe('lib/browsers/firefox', () => {
}), this.options)
})
// CDP is deprecated in Firefox 129 and up.
// In order to enable CDP, we need to set
// remote.active-protocol=2
// @see https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/
// @see https://github.com/cypress-io/cypress/issues/29713
it('sets "remote.active-protocols"=2 to keep CDP enabled for firefox versions 129 and up', async function () {
const executeBeforeBrowserLaunchSpy = sinon.spy(utils, 'executeBeforeBrowserLaunch')
describe('sets "remote.active-protocols"', function () {
// CDP is deprecated in Firefox 129 and up.
// In order to enable CDP, we need to set
// remote.active-protocol=2
// @see https://fxdx.dev/deprecating-cdp-support-in-firefox-embracing-the-future-with-webdriver-bidi/
// @see https://github.com/cypress-io/cypress/issues/29713
it('=2 to enable only CDP', async function () {
const executeBeforeBrowserLaunchSpy = sinon.spy(utils, 'executeBeforeBrowserLaunch')
await firefox.open(this.browser, 'http://', this.options, this.automation)
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(executeBeforeBrowserLaunchSpy).to.have.been.calledWith(this.browser, sinon.match({
preferences: {
'remote.active-protocols': 2,
},
}), this.options)
expect(executeBeforeBrowserLaunchSpy).to.have.been.calledWith(this.browser, sinon.match({
preferences: {
'remote.active-protocols': 2,
},
}), this.options)
})
it('=1 to enable only BiDi', async function () {
this.browser.family = 'firefox'
this.browser.majorVersion = '135'
const executeBeforeBrowserLaunchSpy = sinon.spy(utils, 'executeBeforeBrowserLaunch')
await firefox.open(this.browser, 'http://', this.options, this.automation)
expect(executeBeforeBrowserLaunchSpy).to.have.been.calledWith(this.browser, sinon.match({
preferences: {
'remote.active-protocols': 1,
},
}), this.options)
})
})
it('resolves the browser instance as an event emitter', async function () {
@@ -619,7 +682,7 @@ describe('lib/browsers/firefox', () => {
})
context('firefox-util', () => {
context('#setupRemote', function () {
context('#setupCDP', function () {
it('correctly sets up the remote agent', async function () {
const criClientStub: ICriClient = {
targetId: '',

View File

@@ -56,7 +56,7 @@ export interface LaunchArgs {
onFocusTests?: () => any
}
type NullableMiddlewareHook = (() => void) | null
type NullableMiddlewareHook = ((message: unknown, data: unknown) => void) | null
export type OnRequestEvent = (eventName: string, data: any) => void

View File

@@ -0,0 +1,55 @@
exports['CDP deprecated in Firefox / logs a warning to the user that CDP is deprecated and will be removed in Cypress 15'] = `
====================================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 1 found (simple_passing.cy.js) │
│ Searched: cypress/e2e/simple_passing.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: simple_passing.cy.js (1 of 1)
Since Firefox 129, Chrome DevTools Protocol (CDP) has been deprecated in Firefox. In Firefox 135 and above, Cypress defaults to automating the Firefox browser with WebDriver BiDi. Cypress will no longer support CDP within Firefox in the future and is planned for removal in Cypress 15.
simple passing spec
✓ passes
1 passing
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: X seconds │
│ Spec Ran: simple_passing.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ simple_passing.cy.js XX:XX 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! XX:XX 1 1 - - -
`

View File

@@ -0,0 +1,18 @@
import systemTests from '../lib/system-tests'
describe('CDP deprecated in Firefox', () => {
systemTests.setup()
systemTests.it('logs a warning to the user that CDP is deprecated and will be removed in Cypress 15', {
browser: 'firefox',
processEnv: {
FORCE_FIREFOX_CDP: '1',
},
expectedExitCode: 0,
snapshot: true,
spec: 'simple_passing.cy.js',
onStdout: (stdout) => {
expect(stdout).to.include('Since Firefox 129, Chrome DevTools Protocol (CDP) has been deprecated in Firefox. In Firefox 135 and above, Cypress defaults to automating the Firefox browser with WebDriver BiDi. Cypress will no longer support CDP within Firefox in the future and is planned for removal in Cypress 15.')
},
})
})