diff --git a/.circleci/cache-version.txt b/.circleci/cache-version.txt index 06965ef4647..eaae7eb536a 100644 --- a/.circleci/cache-version.txt +++ b/.circleci/cache-version.txt @@ -1,3 +1,3 @@ # Bump this version to force CI to re-create the cache from scratch. -2-10-25 +2-12-25 diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index a989b1b4b1b..186790fec27 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -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-<>" + elif << parameters.is-firefox-cdp >> ; then + YARN_CMD="cypress:run" + PARALLEL="--parallel --group 5x-driver-cdp-<>" else YARN_CMD="cypress:run" PARALLEL="--parallel --group 5x-driver-<>" @@ -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 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 1a6097f1a26..0f9a99cc4a5 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,8 +1,12 @@ -## 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). diff --git a/packages/driver/cypress/e2e/e2e/service-worker.cy.js b/packages/driver/cypress/e2e/e2e/service-worker.cy.js index 0a20db2e1b3..9596e5175ce 100644 --- a/packages/driver/cypress/e2e/e2e/service-worker.cy.js +++ b/packages/driver/cypress/e2e/e2e/service-worker.cy.js @@ -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') }) }) diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts index bafd08216d1..7fdfb518abb 100644 --- a/packages/errors/src/errors.ts +++ b/packages/errors/src/errors.ts @@ -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. diff --git a/packages/errors/test/unit/visualSnapshotErrors_spec.ts b/packages/errors/test/unit/visualSnapshotErrors_spec.ts index 88d8fa118b8..70b00d81e01 100644 --- a/packages/errors/test/unit/visualSnapshotErrors_spec.ts +++ b/packages/errors/test/unit/visualSnapshotErrors_spec.ts @@ -1112,6 +1112,11 @@ describe('visual error templates', () => { default: [1, 'chrome', 62], } }, + CDP_FIREFOX_DEPRECATED: () => { + return { + default: [], + } + }, BROWSER_PROCESS_CLOSED_UNEXPECTEDLY: () => { return { default: ['chrome'], diff --git a/packages/extension/app/v2/background.js b/packages/extension/app/v2/background.js index 754b18e9b40..7fd06328893 100644 --- a/packages/extension/app/v2/background.js +++ b/packages/extension/app/v2/background.js @@ -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() + } } }) diff --git a/packages/extension/test/integration/v2/background_spec.js b/packages/extension/test/integration/v2/background_spec.js index 0d2fd58a9e5..11c1fc7ea59 100644 --- a/packages/extension/test/integration/v2/background_spec.js +++ b/packages/extension/test/integration/v2/background_spec.js @@ -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, - } + beforeEach(() => { + browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox', version: '135.0.1' }) + }) + 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(browser.webRequest.onBeforeSendHeaders.addListener).to.be.called + }) - expect(result).to.be.undefined + context('BiDi enabled', () => { + it('does not attach onBeforeSendHeaders listener if BiDi is enabled', async function () { + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect() + + expect(browser.webRequest.onBeforeSendHeaders.addListener).not.to.be.called + }) }) - it('does not add header if it is a nested frame', async function () { - const details = { - parentFrameId: 12345, - } + context('CDP enabled', () => { + beforeEach(() => { + browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox', version: '134' }) + }) - 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') - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + await this.connect() - expect(result).to.be.undefined - }) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - 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', - } + expect(result).to.be.undefined + }) - sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + it('does not add header if it is a nested frame', async function () { + const details = { + parentFrameId: 12345, + } - await this.connect() - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - expect(result).to.be.undefined - }) + await this.connect() - 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' }, - ], - } + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + expect(result).to.be.undefined + }) - await this.connect() - const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + 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', + } - expect(result).to.deep.equal({ - requestHeaders: [ - { - name: 'X-Foo', - value: 'Bar', - }, - { - name: 'X-Cypress-Is-AUT-Frame', - value: 'true', - }, - ], + 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 before-headers listener if in non-Firefox browser', async function () { - browser.runtime.getBrowserInfo = 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' }, + ], + } - const onBeforeSendHeaders = 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.deep.equal({ + requestHeaders: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-AUT-Frame', + value: 'true', + }, + ], + }) + }) - expect(onBeforeSendHeaders).not.to.be.called + 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 + }) }) }) diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 7f2a02cf3be..608f933143d 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -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 diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index d102bfc0a4f..db90ccc9133 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -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, diff --git a/packages/proxy/lib/http/util/prerequests.ts b/packages/proxy/lib/http/util/prerequests.ts index 13e28fa789a..041bcf162ff 100644 --- a/packages/proxy/lib/http/util/prerequests.ts +++ b/packages/proxy/lib/http/util/prerequests.ts @@ -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, }) } diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index d58d2eadfb7..aecbac28a39 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -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 diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index 9948aaa937a..eb1c5023803 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -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 diff --git a/packages/server/lib/browsers/bidi_automation.ts b/packages/server/lib/browsers/bidi_automation.ts new file mode 100644 index 00000000000..ea862d4e6b2 --- /dev/null +++ b/packages/server/lib/browsers/bidi_automation.ts @@ -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) + } +} diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index d386dded760..fe837cd8e3d 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -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') diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index 158981fbda0..d5ee8b30dfc 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -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 { @@ -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 { + useWebDriverBiDi: boolean + }): Promise { // 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 + + 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({}) - return browserCriClient + // 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, + + connectToNewSpecCDP, - navigateToUrlClassic, + setupBiDi, setupCDP, } diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 2c6c42f57db..46380d94818 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -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 { + 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,23 +744,27 @@ 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`) + } - const cdpPort = parseInt(new URL(`ws://${webdriverClient.capabilities['moz:debuggerAddress']}`).port) + cdpPort = parseInt(new URL(`ws://${webdriverClient.capabilities['moz:debuggerAddress']}`).port) - debug(`CDP running on port ${cdpPort}`) + debug(`CDP running on port ${cdpPort}`) - // makes it so get getRemoteDebuggingPort() is calculated correctly - process.env.CYPRESS_REMOTE_DEBUGGING_PORT = cdpPort.toString() + // 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) => { @@ -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 diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index b2ef5ba6a4d..79419c2936f 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -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 } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 3c6585a0a72..83f9063552b 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -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 diff --git a/packages/server/test/unit/browsers/bidi_automation_spec.ts b/packages/server/test/unit/browsers/bidi_automation_spec.ts new file mode 100644 index 00000000000..73e8da94e90 --- /dev/null +++ b/packages/server/test/unit/browsers/bidi_automation_spec.ts @@ -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((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') + }) + }) + }) + }) +}) diff --git a/packages/server/test/unit/browsers/firefox_spec.ts b/packages/server/test/unit/browsers/firefox_spec.ts index 47e5fc2b030..e44aa04799d 100644 --- a/packages/server/test/unit/browsers/firefox_spec.ts +++ b/packages/server/test/unit/browsers/firefox_spec.ts @@ -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 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 }) + }) + + 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.installAddOn).to.have.been.calledWith('/path/to/ext', true) + + 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 }))) }) - expect(wdInstance.installAddOn).to.have.been.calledWith('/path/to/ext', 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') - expect(wdInstance.navigateTo).to.have.been.calledWith('http://') + await firefox.open(this.browser, 'http://', this.options, this.automation) - // 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(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: '', diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index cacbb5d42a0..5f978d056b1 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -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 diff --git a/system-tests/__snapshots__/cdp_deprecated_firefox_spec.ts.js b/system-tests/__snapshots__/cdp_deprecated_firefox_spec.ts.js new file mode 100644 index 00000000000..e9c80eb32a4 --- /dev/null +++ b/system-tests/__snapshots__/cdp_deprecated_firefox_spec.ts.js @@ -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 - - - + + +` diff --git a/system-tests/test/cdp_deprecated_firefox_spec.ts b/system-tests/test/cdp_deprecated_firefox_spec.ts new file mode 100644 index 00000000000..b9eb8706e4f --- /dev/null +++ b/system-tests/test/cdp_deprecated_firefox_spec.ts @@ -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.') + }, + }) +})