From 228a117e827682a75eb9aafebe3c6fe5f9f0be20 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 25 Aug 2023 21:05:33 -0500 Subject: [PATCH] feat: swap websocket transport with cdp add binding/evaluate (#27592) Co-authored-by: Brian Mann --- .../e2e/cypress-in-cypress-component.cy.ts | 57 -- .../cypress/e2e/cypress-in-cypress-e2e.cy.ts | 49 -- .../app/cypress/e2e/cypress-in-cypress.cy.ts | 4 +- packages/app/cypress/e2e/runs.cy.ts | 9 +- packages/app/cypress/e2e/settings.cy.ts | 4 +- .../createCloudOrgModal-subscription.cy.ts | 3 +- packages/app/index.d.ts | 4 +- packages/app/src/runner/event-manager.ts | 8 +- packages/data-context/src/DataActions.ts | 6 + packages/data-context/src/DataContext.ts | 154 +--- .../data-context/src/actions/AppActions.ts | 20 + .../src/actions/DataEmitterActions.ts | 27 +- .../src/actions/ElectronActions.ts | 12 +- .../src/actions/MigrationActions.ts | 2 +- .../src/actions/ProjectActions.ts | 17 +- .../src/actions/ServersActions.ts | 54 ++ .../data-context/src/actions/WizardActions.ts | 4 - packages/data-context/src/actions/index.ts | 1 + .../src/data/ProjectConfigManager.ts | 2 +- .../data-context/src/data/coreDataShape.ts | 25 +- .../src/sources/BrowserDataSource.ts | 14 +- .../src/sources/CloudDataSource.ts | 2 +- .../src/sources/GraphQLDataSource.ts | 4 +- .../src/sources/RemoteRequestDataSource.ts | 2 +- .../src/sources/VersionsDataSource.ts | 4 +- .../test/unit/actions/CohortsActions.spec.ts | 6 +- .../test/unit/sources/CloudDataSource.spec.ts | 4 +- .../unit/sources/GraphQLDataSource.spec.ts | 4 +- .../unit/sources/VersionsDataSource.spec.ts | 2 +- .../__snapshot-html__/BROWSER_CRASHED.html | 4 +- .../BROWSER_PAGE_CLOSED_UNEXPECTEDLY.html | 40 + .../BROWSER_PROCESS_CLOSED_UNEXPECTEDLY.html | 40 + .../__snapshot-html__/RENDERER_CRASHED.html | 6 +- .../VIDEO_UPLOAD_ON_PASSES_REMOVED.html | 42 + packages/errors/src/errors.ts | 24 +- .../test/unit/visualSnapshotErrors_spec.ts | 17 +- packages/extension/app/background.js | 2 - .../test/integration/background_spec.js | 2 - .../cypress/e2e/e2ePluginSetup.ts | 4 +- .../frontend-shared/cypress/support/e2e.ts | 2 +- .../frontend-shared/src/graphql/urqlClient.ts | 4 +- .../src/graphql/urqlExchangePubsub.ts | 4 +- .../src/graphql/urqlFetchSocketAdapter.ts | 4 +- packages/graphql/schemas/schema.graphql | 2 + packages/graphql/src/makeGraphQLServer.ts | 10 +- .../schemaTypes/objectTypes/gql-Mutation.ts | 4 +- .../src/schemaTypes/objectTypes/gql-Query.ts | 10 +- .../objectTypes/gql-Subscription.ts | 2 +- .../gql-WizardFrontendFramework.ts | 8 +- packages/graphql/test/stubCloudTypes.ts | 6 +- .../cypress/e2e/choose-a-browser.cy.ts | 14 +- .../launchpad/cypress/e2e/open-mode.cy.ts | 2 +- .../server/lib/browsers/browser-cri-client.ts | 233 ++++-- .../server/lib/browsers/cdp_automation.ts | 10 +- packages/server/lib/browsers/chrome.ts | 43 +- packages/server/lib/browsers/cri-client.ts | 248 ++++-- packages/server/lib/browsers/electron.ts | 77 +- packages/server/lib/browsers/firefox-util.ts | 5 +- packages/server/lib/browsers/firefox.ts | 11 +- packages/server/lib/browsers/index.ts | 22 +- packages/server/lib/browsers/types.ts | 11 +- packages/server/lib/browsers/webkit.ts | 2 +- packages/server/lib/modes/interactive.ts | 2 +- packages/server/lib/open_project.ts | 6 +- packages/server/lib/project-base.ts | 10 +- packages/server/lib/request.js | 26 +- packages/server/lib/routes.ts | 2 + packages/server/lib/server-base.ts | 14 +- packages/server/lib/socket-base.ts | 717 +++++++++--------- packages/server/lib/socket-e2e.ts | 3 +- packages/server/package.json | 3 +- packages/server/test/integration/cdp_spec.ts | 334 ++++++++ .../server/test/integration/cypress_spec.js | 2 + .../server/test/integration/server_spec.js | 18 +- packages/server/test/scripts/watch | 10 + .../test/unit/browsers/browsers_spec.js | 4 +- .../test/unit/browsers/cdp_automation_spec.ts | 12 +- .../server/test/unit/browsers/chrome_spec.js | 11 +- .../test/unit/browsers/cri-client_spec.ts | 20 +- .../test/unit/browsers/electron_spec.js | 64 +- .../server/test/unit/browsers/firefox_spec.ts | 2 + packages/server/test/unit/request_spec.js | 10 +- packages/server/test/unit/socket_spec.js | 10 +- packages/socket/lib/browser.ts | 35 +- packages/socket/lib/cdp-browser.ts | 72 ++ packages/socket/lib/cdp-socket.ts | 174 +++++ packages/socket/lib/socket.ts | 1 + packages/socket/lib/types.ts | 3 + packages/socket/lib/utils.ts | 47 ++ packages/socket/package.json | 5 +- packages/socket/test/fixtures/cypress.png | Bin 0 -> 11881 bytes packages/socket/test/socket_spec.js | 16 +- packages/socket/test/utils_spec.js | 48 ++ packages/types/src/protocol.ts | 1 + .../browser_crash_handling_spec.js | 109 ++- system-tests/lib/system-tests.ts | 2 +- .../e2e/cypress/e2e/chrome_process_kill.cy.js | 5 + .../e2e/cypress/e2e/chrome_tab_close.cy.js | 17 + .../test/browser_crash_handling_spec.js | 71 +- system-tests/test/plugins_spec.js | 2 +- system-tests/test/visit_spec.js | 2 +- yarn.lock | 10 +- 102 files changed, 2286 insertions(+), 1033 deletions(-) create mode 100644 packages/data-context/src/actions/ServersActions.ts create mode 100644 packages/errors/__snapshot-html__/BROWSER_PAGE_CLOSED_UNEXPECTEDLY.html create mode 100644 packages/errors/__snapshot-html__/BROWSER_PROCESS_CLOSED_UNEXPECTEDLY.html create mode 100644 packages/errors/__snapshot-html__/VIDEO_UPLOAD_ON_PASSES_REMOVED.html create mode 100644 packages/server/test/integration/cdp_spec.ts create mode 100755 packages/server/test/scripts/watch create mode 100644 packages/socket/lib/cdp-browser.ts create mode 100644 packages/socket/lib/cdp-socket.ts create mode 100644 packages/socket/lib/types.ts create mode 100644 packages/socket/lib/utils.ts create mode 100644 packages/socket/test/fixtures/cypress.png create mode 100644 packages/socket/test/utils_spec.js create mode 100644 system-tests/projects/e2e/cypress/e2e/chrome_process_kill.cy.js create mode 100644 system-tests/projects/e2e/cypress/e2e/chrome_tab_close.cy.js diff --git a/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts index ed0cb53db0f7..67d9f0366fb4 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts @@ -178,63 +178,6 @@ describe('Cypress In Cypress CT', { viewportWidth: 1500, defaultCommandTimeout: expect(ctx.actions.project.initializeActiveProject).to.be.called }) }) - - it('moves away from runner and back, disconnects websocket and reconnects it correctly', () => { - cy.openProject('cypress-in-cypress') - cy.startAppServer('component') - - cy.visitApp() - cy.contains('TestComponent.spec').click() - cy.waitForSpecToFinish() - cy.get('[data-model-state="passed"]').should('contain', 'renders the test component') - cy.get('.passed > .num').should('contain', 1) - cy.get('.failed > .num').should('contain', '--') - - cy.findByTestId('sidebar-link-runs-page').click() - cy.get('[data-cy="app-header-bar"]').findByText('Runs').should('be.visible') - - cy.findByTestId('sidebar-link-specs-page').click() - cy.get('[data-cy="app-header-bar"]').findByText('Specs').should('be.visible') - - cy.contains('TestComponent.spec').click() - cy.waitForSpecToFinish() - cy.get('[data-model-state="passed"]').should('contain', 'renders the test component') - - cy.window().then((win) => { - const connected = () => win.ws?.connected - - win.ws?.close() - - cy.wrap({ - connected, - }).invoke('connected').should('be.false') - - win.ws?.connect() - - cy.wrap({ - connected, - }).invoke('connected').should('be.true') - }) - - cy.withCtx(async (ctx, o) => { - await ctx.actions.file.writeFileInProject(o.path, ` - import React from 'react' - import { mount } from 'cypress/react' - - describe('TestComponent', () => { - it('renders the new test component', () => { - mount(
Component Test
) - - cy.contains('Component Test').should('be.visible') - }) - }) - `) - }, { path: getPathForPlatform('src/TestComponent.spec.jsx') }) - - cy.get('[data-model-state="passed"]').should('contain', 'renders the new test component') - cy.get('.passed > .num').should('contain', 1) - cy.get('.failed > .num').should('contain', '--') - }) }) context('custom config', () => { diff --git a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts index 677feff1e164..d01574ca0eb8 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts @@ -231,55 +231,6 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout: cy.get('[data-model-state="passed"]').should('contain', 'expected true to be true') }) - it('moves away from runner and back, disconnects websocket and reconnects it correctly', () => { - cy.visitApp() - cy.contains('dom-content.spec').click() - cy.waitForSpecToFinish() - cy.get('[data-model-state="passed"]').should('contain', 'renders the test content') - cy.get('.passed > .num').should('contain', 1) - cy.get('.failed > .num').should('contain', '--') - - cy.findByTestId('sidebar-link-runs-page').click() - cy.get('[data-cy="app-header-bar"]').findByText('Runs').should('be.visible') - - cy.findByTestId('sidebar-link-specs-page').click() - cy.get('[data-cy="app-header-bar"]').findByText('Specs').should('be.visible') - - cy.contains('dom-content.spec').click() - cy.waitForSpecToFinish() - cy.get('[data-model-state="passed"]').should('contain', 'renders the test content') - - cy.window().then((win) => { - const connected = () => win.ws?.connected - - win.ws?.close() - - cy.wrap({ - connected, - }).invoke('connected').should('be.false') - - win.ws?.connect() - - cy.wrap({ - connected, - }).invoke('connected').should('be.true') - }) - - cy.withCtx(async (ctx, o) => { - await ctx.actions.file.writeFileInProject(o.path, ` -describe('Dom Content', () => { - it('renders the new test content', () => { - cy.visit('cypress/e2e/dom-content.html') - }) -}) -`) - }, { path: getPathForPlatform('cypress/e2e/dom-content.spec.js') }) - - cy.get('[data-model-state="passed"]').should('contain', 'renders the new test content') - cy.get('.passed > .num').should('contain', 1) - cy.get('.failed > .num').should('contain', '--') - }) - describe('accessibility', () => { it('has no axe violations in specs list panel', () => { cy.visitApp() diff --git a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts index 2bbc3ab3e462..ea1d68cc29e4 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress.cy.ts @@ -27,7 +27,7 @@ describe('Cypress in Cypress', { viewportWidth: 1500, defaultCommandTimeout: 100 cy.waitForSpecToFinish() cy.withCtx((ctx) => { - ctx.coreData.servers.appSocketServer?.emit('automation:disconnected') + ctx.coreData.servers.cdpSocketServer?.emit('automation:disconnected') }) cy.contains('h3', 'The Cypress extension has disconnected') @@ -403,7 +403,7 @@ describe('Cypress in Cypress', { viewportWidth: 1500, defaultCommandTimeout: 100 cy.withCtx(async (ctx) => { const currentProject = ctx.currentProject?.replaceAll('\\', '/') const specPath = `${currentProject}/cypress/e2e/dom-content.spec.js` - const url = `http://127.0.0.1:${ctx.gqlServerPort}/__launchpad/graphql?` + const url = `http://127.0.0.1:${ctx.coreData.servers.gqlServerPort}/__launchpad/graphql?` const payload = `{"query":"mutation{\\nrunSpec(specPath:\\"${specPath}\\"){\\n__typename\\n... on RunSpecResponse{\\ntestingType\\nbrowser{\\nid\\nname\\n}\\nspec{\\nid\\nname\\n}\\n}\\n}\\n}","variables":null}` ctx.coreData.app.browserStatus = 'open' diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index f53d861efd85..f981244e4152 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -1,4 +1,5 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' + import type { SinonStub } from 'sinon' function moveToRunsPage (): void { @@ -616,26 +617,26 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { it('displays a copy button and copies correct command in Component Testing', () => { scaffoldTestingTypeAndVisitRunsPage('component') cy.withCtx(async (ctx, o) => { - o.sinon.stub(ctx.electronApi, 'copyTextToClipboard') + o.sinon.stub(ctx.config.electronApi, 'copyTextToClipboard') }) cy.get('[data-cy="copy-button"]').click() cy.contains('Copied!') cy.withRetryableCtx((ctx) => { - expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + expect(ctx.config.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') }) }) it('displays a copy button and copies correct command in E2E', () => { scaffoldTestingTypeAndVisitRunsPage('e2e') cy.withCtx(async (ctx, o) => { - o.sinon.stub(ctx.electronApi, 'copyTextToClipboard') + o.sinon.stub(ctx.config.electronApi, 'copyTextToClipboard') }) cy.get('[data-cy="copy-button"]').click() cy.contains('Copied!') cy.withRetryableCtx((ctx) => { - expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + expect(ctx.config.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') }) }) }) diff --git a/packages/app/cypress/e2e/settings.cy.ts b/packages/app/cypress/e2e/settings.cy.ts index 7e143188c004..d257132b42c5 100644 --- a/packages/app/cypress/e2e/settings.cy.ts +++ b/packages/app/cypress/e2e/settings.cy.ts @@ -28,7 +28,7 @@ describe('App: Settings', () => { describe('Cloud Settings', () => { it('shows the projectId section when there is a projectId and shows override from CLI', () => { cy.withCtx(async (ctx, o) => { - o.sinon.stub(ctx.electronApi, 'copyTextToClipboard') + o.sinon.stub(ctx.config.electronApi, 'copyTextToClipboard') }) cy.startAppServer('e2e') @@ -40,7 +40,7 @@ describe('App: Settings', () => { cy.findByText('Copy').click() cy.findByText('Copied!').should('be.visible') cy.withRetryableCtx((ctx) => { - expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('fromCli') + expect(ctx.config.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('fromCli') }) }) diff --git a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts index 1d4365597b50..a168a9255532 100644 --- a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts @@ -1,4 +1,5 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' + import type { SinonStub } from 'sinon' describe('CreateCloudOrgModalSubscription', { viewportWidth: 1200 }, () => { @@ -51,7 +52,7 @@ describe('CreateCloudOrgModalSubscription', { viewportWidth: 1200 }, () => { }) cy.withCtx(async (ctx) => { - await ctx.util.fetch(`http://127.0.0.1:${ctx.gqlServerPort}/cloud-notification?operationName=orgCreated`) + await ctx.util.fetch(`http://127.0.0.1:${ctx.coreData.servers.gqlServerPort}/cloud-notification?operationName=orgCreated`) }) cy.findByText(defaultMessages.runs.connect.modal.selectProject.manageOrgs) diff --git a/packages/app/index.d.ts b/packages/app/index.d.ts index 9f093be6795a..09b67d94269b 100644 --- a/packages/app/index.d.ts +++ b/packages/app/index.d.ts @@ -1,6 +1,6 @@ /// -import type { Socket } from '@packages/socket/lib/browser' +import type { SocketShape } from '@packages/socket/lib/types' import type MobX from 'mobx' import type { EventManager } from './src/runner/event-manager' @@ -21,7 +21,7 @@ export {} */ declare global { interface Window { - ws?: Socket + ws?: SocketShape getEventManager: () => EventManager UnifiedRunner: { /** diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index eb77e4163047..0c8021f9605d 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -7,7 +7,7 @@ import type { LocalBusEmitsMap, LocalBusEventMap, DriverToLocalBus, SocketToDriv import type { RunState, CachedTestState, AutomationElementId, FileDetails, ReporterStartInfo, ReporterRunState } from '@packages/types' import { logger } from './logger' -import type { Socket } from '@packages/socket/lib/browser' +import type { SocketShape } from '@packages/socket/lib/types' import { automation, useRunnerUiStore, useSpecStore } from '../store' import { useScreenshotStore } from '../store/screenshot-store' import { useStudioStore } from '../store/studio-store' @@ -57,7 +57,7 @@ export class EventManager { selectorPlaygroundModel: any cypressInCypressMochaEvents: CypressInCypressMochaEvent[] = [] // Used for testing the experimentalSingleTabRunMode experiment. Ensures AUT is correctly destroyed between specs. - ws: Socket + ws: SocketShape specStore: ReturnType studioStore: ReturnType @@ -68,7 +68,7 @@ export class EventManager { private Mobx: typeof MobX, // selectorPlaygroundModel singleton selectorPlaygroundModel: any, - ws: Socket, + ws: SocketShape, ) { this.selectorPlaygroundModel = selectorPlaygroundModel this.ws = ws @@ -134,7 +134,7 @@ export class EventManager { telemetry.setRootContext(context) }) - this.ws.on('automation:push:message', (msg, data = {}) => { + this.ws.on('automation:push:message', (msg, data: any = {}) => { if (!Cypress) return switch (msg) { diff --git a/packages/data-context/src/DataActions.ts b/packages/data-context/src/DataActions.ts index aaad9e3c4c92..549f2da0a6c7 100644 --- a/packages/data-context/src/DataActions.ts +++ b/packages/data-context/src/DataActions.ts @@ -10,6 +10,7 @@ import { BrowserActions, DevActions, AuthActions, + ServersActions, CohortsActions, CodegenActions, CloudProjectActions, @@ -78,6 +79,11 @@ export class DataActions { return new BrowserActions(this.ctx) } + @cached + get servers () { + return new ServersActions(this.ctx) + } + @cached get versions () { return new VersionsActions(this.ctx) diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index c21f59f6d785..e59f89233088 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -1,7 +1,6 @@ import type { AllModeOptions } from '@packages/types' import fsExtra from 'fs-extra' import path from 'path' -import util from 'util' import chalk from 'chalk' import assert from 'assert' import str from 'underscore.string' @@ -29,20 +28,18 @@ import { MigrationDataSource, RelevantRunsDataSource, RelevantRunSpecsDataSource, -} from './sources/' + VersionsDataSource, + ErrorDataSource, + GraphQLDataSource, + RemoteRequestDataSource, +} from './sources' import { cached } from './util/cached' import type { GraphQLSchema, OperationTypeNode, DocumentNode } from 'graphql' -import type { IncomingHttpHeaders, Server } from 'http' -import type { AddressInfo } from 'net' +import type { IncomingHttpHeaders } from 'http' import type { App as ElectronApp } from 'electron' -import { VersionsDataSource } from './sources/VersionsDataSource' -import type { SocketIONamespace, SocketIOServer } from '@packages/socket' import { globalPubSub } from '.' import { ProjectLifecycleManager } from './data/ProjectLifecycleManager' import type { CypressError } from '@packages/errors' -import { ErrorDataSource } from './sources/ErrorDataSource' -import { GraphQLDataSource } from './sources/GraphQLDataSource' -import { RemoteRequestDataSource } from './sources/RemoteRequestDataSource' import { resetIssuedWarnings } from '@packages/config' const IS_DEV_ENV = process.env.CYPRESS_INTERNAL_ENV !== 'production' @@ -99,20 +96,16 @@ export class DataContext { this.lifecycleManager = new ProjectLifecycleManager(this) } - get git () { - return this.coreData.currentProjectGitInfo - } - - get schema () { - return this._config.schema + get config () { + return this._config } - get schemaCloud () { - return this._config.schemaCloud + get git () { + return this.coreData.currentProjectGitInfo } get isRunMode () { - return this._config.mode === 'run' + return this.config.mode === 'run' } get isOpenMode () { @@ -129,26 +122,6 @@ export class DataContext { return new RemoteRequestDataSource() } - get electronApp () { - return this._config.electronApp - } - - get electronApi () { - return this._config.electronApi - } - - get localSettingsApi () { - return this._config.localSettingsApi - } - - get cohortsApi () { - return this._config.cohortsApi - } - - get isGlobalMode () { - return this.appData.isGlobalMode - } - get modeOptions () { return this._modeOptions } @@ -157,26 +130,6 @@ export class DataContext { return this._coreData } - get user () { - return this.coreData.user - } - - get browserList () { - return this.coreData.app.browsers - } - - get nodePath () { - return this.coreData.app.nodePath - } - - get baseError () { - return this.coreData.diagnostics.error - } - - get warnings () { - return this.coreData.diagnostics.warnings - } - @cached get file () { return new FileDataSource(this) @@ -201,19 +154,11 @@ export class DataContext { return new DataActions(this) } - get appData () { - return this.coreData.app - } - @cached get wizard () { return new WizardDataSource(this) } - get wizardData () { - return this.coreData.wizard - } - get currentProject () { return this.coreData.currentProject } @@ -237,7 +182,7 @@ export class DataContext { get cloud () { return new CloudDataSource({ fetch: (...args) => this.util.fetch(...args), - getUser: () => this.user, + getUser: () => this.coreData.user, logout: () => this.actions.auth.logout().catch(this.logTraceError), invalidateClientUrqlCache: () => this.graphql.invalidateClientUrqlCache(this), headers: { @@ -276,41 +221,6 @@ export class DataContext { return new MigrationDataSource(this) } - get projectsList () { - return this.coreData.app.projects - } - - // Servers - - setAppServerPort (port: number | undefined) { - this.update((d) => { - d.servers.appServerPort = port ?? null - }) - } - - setAppSocketServer (socketServer: SocketIOServer | undefined) { - this.update((d) => { - d.servers.appSocketServer?.disconnectSockets(true) - d.servers.appSocketNamespace?.disconnectSockets(true) - d.servers.appSocketServer = socketServer - d.servers.appSocketNamespace = socketServer?.of('/data-context') - }) - } - - setGqlServer (srv: Server) { - this.update((d) => { - d.servers.gqlServer = srv - d.servers.gqlServerPort = (srv.address() as AddressInfo).port - }) - } - - setGqlSocketServer (socketServer: SocketIONamespace | undefined) { - this.update((d) => { - d.servers.gqlSocketServer?.disconnectSockets(true) - d.servers.gqlSocketServer = socketServer - }) - } - /** * This will be replaced with Immer, for immutable state updates. */ @@ -318,14 +228,6 @@ export class DataContext { updater(this._coreData) } - get appServerPort () { - return this.coreData.servers.appServerPort - } - - get gqlServerPort () { - return this.coreData.servers.gqlServerPort - } - // Utilities @cached @@ -340,13 +242,13 @@ export class DataContext { get _apis () { return { - appApi: this._config.appApi, - authApi: this._config.authApi, - browserApi: this._config.browserApi, - projectApi: this._config.projectApi, - electronApi: this._config.electronApi, - localSettingsApi: this._config.localSettingsApi, - cohortsApi: this._config.cohortsApi, + appApi: this.config.appApi, + authApi: this.config.authApi, + browserApi: this.config.browserApi, + projectApi: this.config.projectApi, + electronApi: this.config.electronApi, + localSettingsApi: this.config.localSettingsApi, + cohortsApi: this.config.cohortsApi, } } @@ -427,14 +329,8 @@ export class DataContext { } async destroy () { - let destroyGqlServer = () => Promise.resolve() - - if (this.coreData.servers.gqlServer?.destroy) { - destroyGqlServer = util.promisify(this.coreData.servers.gqlServer.destroy) - } - return Promise.all([ - destroyGqlServer(), + this.actions.servers.destroyGqlServer(), this._reset(), ]) } @@ -455,8 +351,8 @@ export class DataContext { } _reset () { - this.setAppSocketServer(undefined) - this.setGqlSocketServer(undefined) + this.actions.servers.setAppSocketServer(undefined) + this.actions.servers.setGqlSocketServer(undefined) resetIssuedWarnings() @@ -470,10 +366,10 @@ export class DataContext { async initializeMode () { assert(!this.coreData.hasInitializedMode) - this.coreData.hasInitializedMode = this._config.mode - if (this._config.mode === 'run') { + this.coreData.hasInitializedMode = this.config.mode + if (this.config.mode === 'run') { await this.lifecycleManager.initializeRunMode(this.coreData.currentTestingType) - } else if (this._config.mode === 'open') { + } else if (this.config.mode === 'open') { await this.initializeOpenMode() await this.lifecycleManager.initializeOpenMode(this.coreData.currentTestingType) } else { diff --git a/packages/data-context/src/actions/AppActions.ts b/packages/data-context/src/actions/AppActions.ts index fc1e37d6dfe8..f2fb08b892b4 100644 --- a/packages/data-context/src/actions/AppActions.ts +++ b/packages/data-context/src/actions/AppActions.ts @@ -1,3 +1,4 @@ +import type { BrowserStatus } from '@packages/types' import type { DataContext } from '..' export interface AppApiShape { @@ -21,4 +22,23 @@ export class AppActions { async ensureAppDataDirExists () { await this.ctx._apis.appApi.appData.ensure() } + + setBrowserStatus (browserStatus: BrowserStatus) { + this.ctx.update((d) => { + d.app.browserStatus = browserStatus + + // when we close the browser null out the user agent + if (browserStatus === 'closed') { + d.app.browserUserAgent = null + } + }) + + this.ctx.emitter.browserStatusChange() + } + + setBrowserUserAgent (userAgent?: string) { + this.ctx.update((d) => { + d.app.browserUserAgent = userAgent || null + }) + } } diff --git a/packages/data-context/src/actions/DataEmitterActions.ts b/packages/data-context/src/actions/DataEmitterActions.ts index e006bb0b8877..90215793c763 100644 --- a/packages/data-context/src/actions/DataEmitterActions.ts +++ b/packages/data-context/src/actions/DataEmitterActions.ts @@ -168,6 +168,7 @@ export class DataEmitterActions extends DataEmitterEvents { */ toApp () { this.ctx.coreData.servers.appSocketNamespace?.emit('graphql-refetch') + this.ctx.coreData.servers.cdpSocketNamespace?.emit('graphql-refetch') } /** @@ -183,13 +184,25 @@ export class DataEmitterActions extends DataEmitterEvents { * source, and respond with the data before the initial hit was able to resolve */ notifyClientRefetch (target: 'app' | 'launchpad', operation: string, field: string, variables: any) { - const server = target === 'app' ? this.ctx.coreData.servers.appSocketNamespace : this.ctx.coreData.servers.gqlSocketServer - - server?.emit('graphql-refetch', { - field, - operation, - variables, - }) + if (target === 'app') { + this.ctx.coreData.servers.appSocketNamespace?.emit('graphql-refetch', { + field, + operation, + variables, + }) + + this.ctx.coreData.servers.cdpSocketNamespace?.emit('graphql-refetch', { + field, + operation, + variables, + }) + } else { + this.ctx.coreData.servers.gqlSocketServer?.emit('graphql-refetch', { + field, + operation, + variables, + }) + } } /** diff --git a/packages/data-context/src/actions/ElectronActions.ts b/packages/data-context/src/actions/ElectronActions.ts index 76eedec53d9f..fd27e726ccbf 100644 --- a/packages/data-context/src/actions/ElectronActions.ts +++ b/packages/data-context/src/actions/ElectronActions.ts @@ -46,7 +46,7 @@ export class ElectronActions { this.electron.browserWindow?.show() if (this.isMac) { - this.ctx.electronApp?.dock.show().catch((e) => { + this.ctx.config.electronApp?.dock.show().catch((e) => { this.ctx.logTraceError(e) }) } else { @@ -64,11 +64,11 @@ export class ElectronActions { } openExternal (url: string) { - this.ctx.electronApi.openExternal(url) + this.ctx.config.electronApi.openExternal(url) } showItemInFolder (url: string) { - this.ctx.electronApi.showItemInFolder(url) + this.ctx.config.electronApi.showItemInFolder(url) } showOpenDialog () { @@ -78,7 +78,7 @@ export class ElectronActions { properties: ['openDirectory'], } - return this.ctx.electronApi.showOpenDialog(props) + return this.ctx.config.electronApi.showOpenDialog(props) .then((obj) => { // return the first path since there can only ever // be a single directory selection @@ -108,13 +108,13 @@ export class ElectronActions { } // attach to window so it displays as a modal rather than a standalone window - return this.ctx.electronApi.showSaveDialog(this.electron.browserWindow, props).then((obj) => { + return this.ctx.config.electronApi.showSaveDialog(this.electron.browserWindow, props).then((obj) => { return obj.filePath || null }) } showSystemNotification (title: string, body: string, onClick: () => void) { - const notification = this.ctx.electronApi.createNotification(title, body) + const notification = this.ctx.config.electronApi.createNotification(title, body) notifications.add(notification) diff --git a/packages/data-context/src/actions/MigrationActions.ts b/packages/data-context/src/actions/MigrationActions.ts index c154784b96c6..ab40842bd032 100644 --- a/packages/data-context/src/actions/MigrationActions.ts +++ b/packages/data-context/src/actions/MigrationActions.ts @@ -176,7 +176,7 @@ export class MigrationActions { throw Error('cannot do migration without currentProject!') } - if (this.ctx.isGlobalMode) { + if (this.ctx.coreData.app.isGlobalMode) { const version = await this.locallyInstalledCypressVersion(this.ctx.currentProject) if (!version) { diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index 740ebd4ffd8d..167ee68cb433 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -109,6 +109,7 @@ export class ProjectActions { d.forceReconfigureProject = null d.scaffoldedFiles = null d.app.browserStatus = 'closed' + d.app.browserUserAgent = null }) // Also clear any data associated with the linked cloud project @@ -120,12 +121,10 @@ export class ProjectActions { await this.api.closeActiveProject() } - private get projects () { - return this.ctx.projectsList - } - private set projects (projects: ProjectShape[]) { - this.ctx.coreData.app.projects = projects + this.ctx.update((d) => { + d.app.projects = projects + }) } openDirectoryInIDE (projectPath: string) { @@ -169,11 +168,7 @@ export class ProjectActions { async loadProjects () { const projectRoots = await this.api.getProjectRootsFromCache() - this.ctx.update((d) => { - d.app.projects = [...projectRoots] - }) - - return this.projects + return this.projects = [...projectRoots] } async initializeActiveProject (options: OpenProjectLaunchOptions = {}) { @@ -454,7 +449,7 @@ export class ProjectActions { return } - const baseUrlWarning = this.ctx.warnings.find((e) => e.cypressError.type === 'CANNOT_CONNECT_BASE_URL_WARNING') + const baseUrlWarning = this.ctx.coreData.diagnostics.warnings.find((e) => e.cypressError.type === 'CANNOT_CONNECT_BASE_URL_WARNING') if (baseUrlWarning) { this.ctx.actions.error.clearWarning(baseUrlWarning.id) diff --git a/packages/data-context/src/actions/ServersActions.ts b/packages/data-context/src/actions/ServersActions.ts new file mode 100644 index 000000000000..ea13acdee999 --- /dev/null +++ b/packages/data-context/src/actions/ServersActions.ts @@ -0,0 +1,54 @@ +import util from 'util' + +import type { AddressInfo } from 'net' +import type { Server } from 'http' +import type { SocketIONamespace, SocketIOServer } from '@packages/socket' +import type { DataContext } from '..' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' + +export class ServersActions { + constructor (private ctx: DataContext) {} + + setAppServerPort (port: number | undefined) { + this.ctx.update((d) => { + d.servers.appServerPort = port ?? null + }) + } + + setAppSocketServer ({ socketIo, cdpIo }: { socketIo?: SocketIOServer, cdpIo?: CDPSocketServer } = { socketIo: undefined, cdpIo: undefined }) { + this.ctx.update((d) => { + d.servers.appSocketServer?.disconnectSockets(true) + d.servers.appSocketNamespace?.disconnectSockets(true) + d.servers.cdpSocketServer?.disconnectSockets(true) + d.servers.cdpSocketNamespace?.disconnectSockets(true) + d.servers.appSocketServer = socketIo + d.servers.appSocketNamespace = socketIo?.of('/data-context') + d.servers.cdpSocketServer = cdpIo + d.servers.cdpSocketNamespace = cdpIo?.of('/data-context') + }) + } + + setGqlServer (srv: Server) { + this.ctx.update((d) => { + d.servers.gqlServer = srv + d.servers.gqlServerPort = (srv.address() as AddressInfo).port + }) + } + + setGqlSocketServer (socketServer: SocketIONamespace | undefined) { + this.ctx.update((d) => { + d.servers.gqlSocketServer?.disconnectSockets(true) + d.servers.gqlSocketServer = socketServer + }) + } + + async destroyGqlServer () { + const destroy = this.ctx.coreData.servers.gqlServer?.destroy + + if (!destroy) { + return + } + + return util.promisify(destroy)() + } +} diff --git a/packages/data-context/src/actions/WizardActions.ts b/packages/data-context/src/actions/WizardActions.ts index 9aa87d96f3ed..4e8f54c91131 100644 --- a/packages/data-context/src/actions/WizardActions.ts +++ b/packages/data-context/src/actions/WizardActions.ts @@ -20,10 +20,6 @@ export class WizardActions { return this.ctx.currentProject } - private get data () { - return this.ctx.wizardData - } - setFramework (framework: Cypress.ResolvedComponentFrameworkDefinition | null): void { const next = this.ctx.coreData.wizard.frameworks.find((x) => x.type === framework?.type) diff --git a/packages/data-context/src/actions/index.ts b/packages/data-context/src/actions/index.ts index 2d64497c785f..9616fd729e19 100644 --- a/packages/data-context/src/actions/index.ts +++ b/packages/data-context/src/actions/index.ts @@ -17,5 +17,6 @@ export * from './LocalSettingsActions' export * from './MigrationActions' export * from './NotificationActions' export * from './ProjectActions' +export * from './ServersActions' export * from './VersionsActions' export * from './WizardActions' diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts index f1ca9634cc51..3fd333242e4a 100644 --- a/packages/data-context/src/data/ProjectConfigManager.ts +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -362,7 +362,7 @@ export class ProjectConfigManager { } this._eventsIpc = new ProjectConfigIpc( - this.options.ctx.nodePath, + this.options.ctx.coreData.app.nodePath, this.options.projectRoot, this.configFilePath, this.options.configFile, diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 14e1e9e9a18e..d14d17cfedb6 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -8,6 +8,7 @@ import type { Server } from 'http' import type { ErrorWrapperSource } from '@packages/errors' import type { EventCollectorSource, GitDataSource, LegacyCypressConfigJson } from '../sources' import { machineId as getMachineId } from 'node-machine-id' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' export type Maybe = T | null | undefined @@ -23,6 +24,18 @@ export interface ProjectShape { savedState?: () => Promise> } +export interface ServersDataShape { + appServer?: Maybe + appServerPort?: Maybe + appSocketServer?: Maybe + appSocketNamespace?: Maybe + cdpSocketServer?: CDPSocketServer | undefined + cdpSocketNamespace?: CDPSocketServer | undefined + gqlServer?: Maybe + gqlServerPort?: Maybe + gqlSocketServer?: Maybe +} + export interface DevStateShape { refreshState: null | string } @@ -63,6 +76,7 @@ export interface AppDataShape { projects: ProjectShape[] nodePath: Maybe browserStatus: BrowserStatus + browserUserAgent: string | null relaunchBrowser: boolean } @@ -133,15 +147,7 @@ export interface CoreDataShape { machineId: Promise machineBrowsers: Promise | null allBrowsers: Promise | null - servers: { - appServer?: Maybe - appServerPort?: Maybe - appSocketServer?: Maybe - appSocketNamespace?: Maybe - gqlServer?: Maybe - gqlServerPort?: Maybe - gqlSocketServer?: Maybe - } + servers: ServersDataShape hasInitializedMode: 'run' | 'open' | null cloudGraphQLError: ErrorWrapperSource | null dev: DevStateShape @@ -189,6 +195,7 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa projects: [], nodePath: modeOptions.userNodePath, browserStatus: 'closed', + browserUserAgent: null, relaunchBrowser: false, }, localSettings: { diff --git a/packages/data-context/src/sources/BrowserDataSource.ts b/packages/data-context/src/sources/BrowserDataSource.ts index 87ac213a54b9..e8a933f58b9d 100644 --- a/packages/data-context/src/sources/BrowserDataSource.ts +++ b/packages/data-context/src/sources/BrowserDataSource.ts @@ -1,9 +1,9 @@ -import type { FoundBrowser, BrowserStatus } from '@packages/types' -import os from 'os' import execa from 'execa' +import _ from 'lodash' +import os from 'os' +import type { FoundBrowser } from '@packages/types' import type { DataContext } from '..' -import _ from 'lodash' let isPowerShellAvailable: undefined | boolean let powerShellPromise: Promise | undefined @@ -131,12 +131,4 @@ export class BrowserDataSource { isVersionSupported (obj: FoundBrowser) { return Boolean(!obj.unsupportedVersion) } - - setBrowserStatus (browserStatus: BrowserStatus) { - this.ctx.update((d) => { - d.app.browserStatus = browserStatus - }) - - this.ctx.emitter.browserStatusChange() - } } diff --git a/packages/data-context/src/sources/CloudDataSource.ts b/packages/data-context/src/sources/CloudDataSource.ts index d4f107f67f4c..510e8fc4a136 100644 --- a/packages/data-context/src/sources/CloudDataSource.ts +++ b/packages/data-context/src/sources/CloudDataSource.ts @@ -170,7 +170,7 @@ export class CloudDataSource { delegateCloudField (params: CloudExecuteDelegateFieldParams) { return delegateToSchema({ operation: 'query', - schema: params.ctx.schemaCloud, + schema: params.ctx.config.schemaCloud, fieldName: params.field, fieldNodes: params.info.fieldNodes, info: params.info, diff --git a/packages/data-context/src/sources/GraphQLDataSource.ts b/packages/data-context/src/sources/GraphQLDataSource.ts index 23b7220c302f..3536acffe42d 100644 --- a/packages/data-context/src/sources/GraphQLDataSource.ts +++ b/packages/data-context/src/sources/GraphQLDataSource.ts @@ -74,7 +74,7 @@ export class GraphQLDataSource { }, InlineFragment: (node) => { // Remove any non-cloud types from the node - if (node.typeCondition && !ctx.schemaCloud.getType(node.typeCondition.name.value)) { + if (node.typeCondition && !ctx.config.schemaCloud.getType(node.typeCondition.name.value)) { return null } @@ -85,7 +85,7 @@ export class GraphQLDataSource { // Execute the node field against the cloud schema return execute({ - schema: ctx.schemaCloud, + schema: ctx.config.schemaCloud, contextValue: ctx, variableValues: info.variableValues, document: { diff --git a/packages/data-context/src/sources/RemoteRequestDataSource.ts b/packages/data-context/src/sources/RemoteRequestDataSource.ts index 80cb21a2cee1..67b4cbfed821 100644 --- a/packages/data-context/src/sources/RemoteRequestDataSource.ts +++ b/packages/data-context/src/sources/RemoteRequestDataSource.ts @@ -306,7 +306,7 @@ export class RemoteRequestDataSource { const fieldNodes = this.#getDataFieldNodes(info) const referencedVariableValues = this.#getReferencedVariables(fieldNodes, info.operation.variableDefinitions ?? []) - const queryFieldDef = ctx.schemaCloud.getQueryType()?.getFields()[fieldConfig.remoteQueryField] + const queryFieldDef = ctx.config.schemaCloud.getQueryType()?.getFields()[fieldConfig.remoteQueryField] assert(queryFieldDef, `Unknown remote query field ${fieldConfig.remoteQueryField}`) diff --git a/packages/data-context/src/sources/VersionsDataSource.ts b/packages/data-context/src/sources/VersionsDataSource.ts index b3046a17e283..4c464884374c 100644 --- a/packages/data-context/src/sources/VersionsDataSource.ts +++ b/packages/data-context/src/sources/VersionsDataSource.ts @@ -131,7 +131,7 @@ export class VersionsDataSource { debug('#getLatestVersion') - const preferences = await this.ctx.localSettingsApi.getPreferences() + const preferences = await this.ctx.config.localSettingsApi.getPreferences() const notificationPreferences: ('started' | 'failing' | 'passed' | 'failed' | 'cancelled' | 'errored')[] = [ ...preferences.notifyWhenRunCompletes ?? [], ] @@ -153,7 +153,7 @@ export class VersionsDataSource { 'x-arch': os.arch(), 'x-notifications': notificationPreferences.join(','), 'x-initial-launch': String(this._initialLaunch), - 'x-logged-in': String(!!this.ctx.user), + 'x-logged-in': String(!!this.ctx.coreData.user), } if (this._currentTestingType) { diff --git a/packages/data-context/test/unit/actions/CohortsActions.spec.ts b/packages/data-context/test/unit/actions/CohortsActions.spec.ts index 89fe1ac74dab..d68acebfd5d0 100644 --- a/packages/data-context/test/unit/actions/CohortsActions.spec.ts +++ b/packages/data-context/test/unit/actions/CohortsActions.spec.ts @@ -23,7 +23,7 @@ describe('CohortsActions', () => { const cohort = await actions.getCohort(name) expect(cohort).to.be.undefined - expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(name) + expect(ctx.config.cohortsApi.getCohort).to.have.been.calledWith(name) }) it('should return cohort if in cache', async () => { @@ -37,7 +37,7 @@ describe('CohortsActions', () => { const cohortReturned = await actions.getCohort(cohort.name) expect(cohortReturned).to.eq(cohort) - expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(cohort.name) + expect(ctx.config.cohortsApi.getCohort).to.have.been.calledWith(cohort.name) }) }) @@ -50,7 +50,7 @@ describe('CohortsActions', () => { const pickedCohort = await actions.determineCohort(cohortConfig.name, cohortConfig.cohorts) - expect(ctx.cohortsApi.insertCohort).to.have.been.calledOnceWith({ name: cohortConfig.name, cohort: match.string }) + expect(ctx.config.cohortsApi.insertCohort).to.have.been.calledOnceWith({ name: cohortConfig.name, cohort: match.string }) expect(cohortConfig.cohorts.includes(pickedCohort.cohort)).to.be.true }) }) diff --git a/packages/data-context/test/unit/sources/CloudDataSource.spec.ts b/packages/data-context/test/unit/sources/CloudDataSource.spec.ts index 229d5f0f092b..a5bea4003936 100644 --- a/packages/data-context/test/unit/sources/CloudDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/CloudDataSource.spec.ts @@ -315,7 +315,7 @@ describe('CloudDataSource', () => { const result = await execute({ rootValue: {}, document: CLOUD_PROJECT_QUERY, - schema: ctx.schema, + schema: ctx.config.schema, contextValue: ctx, }) @@ -333,7 +333,7 @@ describe('CloudDataSource', () => { const result2 = await execute({ rootValue: {}, document: CLOUD_PROJECT_QUERY, - schema: ctx.schema, + schema: ctx.config.schema, contextValue: ctx, }) diff --git a/packages/data-context/test/unit/sources/GraphQLDataSource.spec.ts b/packages/data-context/test/unit/sources/GraphQLDataSource.spec.ts index 0e732c9874a0..03005d58fae1 100644 --- a/packages/data-context/test/unit/sources/GraphQLDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/GraphQLDataSource.spec.ts @@ -25,7 +25,7 @@ describe('GraphQLDataSource', () => { ctx.project.projectId = async () => 'abc123' pushFragmentIterator = await Promise.resolve(subscribe({ - schema: ctx.schema, + schema: ctx.config.schema, contextValue: ctx, document: parse(`subscription { pushFragment { @@ -47,7 +47,7 @@ describe('GraphQLDataSource', () => { function executeQuery (query: string) { return Promise.resolve(execute({ document: parse(query), - schema: ctx.schema, + schema: ctx.config.schema, contextValue: ctx, })) } diff --git a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts index aeffdcbf4e63..297e439ef00f 100644 --- a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts @@ -277,7 +277,7 @@ describe('VersionsDataSource', () => { }) it('generates x-notifications header', async () => { - (ctx.localSettingsApi.getPreferences as sinon.SinonStub).callsFake(() => { + (ctx.config.localSettingsApi.getPreferences as sinon.SinonStub).callsFake(() => { return { notifyWhenRunCompletes: ['errored'], notifyWhenRunStarts: true, diff --git a/packages/errors/__snapshot-html__/BROWSER_CRASHED.html b/packages/errors/__snapshot-html__/BROWSER_CRASHED.html index da8c4e4d4ebe..7d1d2394d60b 100644 --- a/packages/errors/__snapshot-html__/BROWSER_CRASHED.html +++ b/packages/errors/__snapshot-html__/BROWSER_CRASHED.html @@ -36,7 +36,7 @@
We detected that the Chrome process just crashed with code 'code' and signal 'signal'.
 
-We have failed the current test and have relaunched Chrome.
+We have failed the current spec but will continue running the next spec.
 
 This can happen for many different reasons:
 
@@ -44,5 +44,5 @@
 - You are running lots of tests on a memory intense application
 - You are running in a memory starved VM environment
 - There are problems with your GPU / GPU drivers
-- There are browser bugs
+- There are browser bugs
 
\ No newline at end of file diff --git a/packages/errors/__snapshot-html__/BROWSER_PAGE_CLOSED_UNEXPECTEDLY.html b/packages/errors/__snapshot-html__/BROWSER_PAGE_CLOSED_UNEXPECTEDLY.html new file mode 100644 index 000000000000..c7f1e71af83d --- /dev/null +++ b/packages/errors/__snapshot-html__/BROWSER_PAGE_CLOSED_UNEXPECTEDLY.html @@ -0,0 +1,40 @@ + + + + + + + + + + + +
We detected that the chrome tab running Cypress tests closed unexpectedly.
+
+We have failed the current spec and aborted the run.
+
\ No newline at end of file diff --git a/packages/errors/__snapshot-html__/BROWSER_PROCESS_CLOSED_UNEXPECTEDLY.html b/packages/errors/__snapshot-html__/BROWSER_PROCESS_CLOSED_UNEXPECTEDLY.html new file mode 100644 index 000000000000..5ac27b0e06c8 --- /dev/null +++ b/packages/errors/__snapshot-html__/BROWSER_PROCESS_CLOSED_UNEXPECTEDLY.html @@ -0,0 +1,40 @@ + + + + + + + + + + + +
We detected that the chrome browser process closed unexpectedly.
+
+We have failed the current spec and aborted the run.
+
\ No newline at end of file diff --git a/packages/errors/__snapshot-html__/RENDERER_CRASHED.html b/packages/errors/__snapshot-html__/RENDERER_CRASHED.html index 6b13cb849c8a..ad4daa2bfd1a 100644 --- a/packages/errors/__snapshot-html__/RENDERER_CRASHED.html +++ b/packages/errors/__snapshot-html__/RENDERER_CRASHED.html @@ -34,7 +34,9 @@ -
We detected that the Chromium Renderer process just crashed.
+    
We detected that the Electron Renderer process just crashed.
+
+We have failed the current spec but will continue running the next spec.
 
 This can happen for a number of different reasons.
 
@@ -45,5 +47,5 @@
 
 You can learn more here:
 
-https://on.cypress.io/renderer-process-crashed
+https://on.cypress.io/renderer-process-crashed
 
\ No newline at end of file diff --git a/packages/errors/__snapshot-html__/VIDEO_UPLOAD_ON_PASSES_REMOVED.html b/packages/errors/__snapshot-html__/VIDEO_UPLOAD_ON_PASSES_REMOVED.html new file mode 100644 index 000000000000..8452a5f91d80 --- /dev/null +++ b/packages/errors/__snapshot-html__/VIDEO_UPLOAD_ON_PASSES_REMOVED.html @@ -0,0 +1,42 @@ + + + + + + + + + + + +
The videoUploadOnPasses configuration option was removed in Cypress version 13.0.0.
+
+You can safely remove this option from your config.
+
+https://on.cypress.io/migration-guide
+
\ No newline at end of file diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts index a7dd06bd9f39..bb27280ca0dd 100644 --- a/packages/errors/src/errors.ts +++ b/packages/errors/src/errors.ts @@ -634,9 +634,11 @@ export const AllCypressErrors = { ${fmt.listItems(globPaths, { color: 'blue', prefix: ' > ' })}` }, - RENDERER_CRASHED: () => { + RENDERER_CRASHED: (browserName: string) => { return errTemplate`\ - We detected that the Chromium Renderer process just crashed. + We detected that the ${fmt.highlight(browserName)} Renderer process just crashed. + + We have failed the current spec but will continue running the next spec. This can happen for a number of different reasons. @@ -649,11 +651,11 @@ export const AllCypressErrors = { https://on.cypress.io/renderer-process-crashed` }, - BROWSER_CRASHED: (browser: string, code: string | number, signal: string) => { + BROWSER_CRASHED: (browserName: string, code: string | number, signal: string) => { return errTemplate`\ - We detected that the ${fmt.highlight(browser)} process just crashed with code '${fmt.highlight(code)}' and signal '${fmt.highlight(signal)}'. + We detected that the ${fmt.highlight(browserName)} process just crashed with code '${fmt.highlight(code)}' and signal '${fmt.highlight(signal)}'. - We have failed the current test and have relaunched ${fmt.highlight(browser)}. + We have failed the current spec but will continue running the next spec. This can happen for many different reasons: @@ -1068,6 +1070,18 @@ 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})`)}` }, + BROWSER_PROCESS_CLOSED_UNEXPECTEDLY: (browserName: string) => { + return errTemplate`\ + We detected that the ${fmt.highlight(browserName)} browser process closed unexpectedly. + + We have failed the current spec and aborted the run.` + }, + BROWSER_PAGE_CLOSED_UNEXPECTEDLY: (browserName: string) => { + return errTemplate`\ + We detected that the ${fmt.highlight(browserName)} tab running Cypress tests closed unexpectedly. + + We have failed the current spec and aborted the run.` + }, UNEXPECTED_BEFORE_BROWSER_LAUNCH_PROPERTIES: (arg1: string[], arg2: string[]) => { return errTemplate`\ The ${fmt.highlight('launchOptions')} object returned by your plugin's ${fmt.highlightSecondary(`before:browser:launch`)} handler contained unexpected properties: diff --git a/packages/errors/test/unit/visualSnapshotErrors_spec.ts b/packages/errors/test/unit/visualSnapshotErrors_spec.ts index dff123f95f2c..ff89d3d93882 100644 --- a/packages/errors/test/unit/visualSnapshotErrors_spec.ts +++ b/packages/errors/test/unit/visualSnapshotErrors_spec.ts @@ -292,6 +292,11 @@ const makeErr = () => { return err as Error & {stack: string} } +process.on('uncaughtException', (err) => { + console.error(err) + process.exit(1) +}) + describe('visual error templates', () => { const errorType = (process.env.ERROR_TYPE || '*') as CypressErrorType @@ -705,7 +710,7 @@ describe('visual error templates', () => { }, RENDERER_CRASHED: () => { return { - default: [], + default: ['Electron'], } }, BROWSER_CRASHED: () => { @@ -1019,6 +1024,16 @@ describe('visual error templates', () => { default: [1, 'chrome', 62], } }, + BROWSER_PROCESS_CLOSED_UNEXPECTEDLY: () => { + return { + default: ['chrome'], + } + }, + BROWSER_PAGE_CLOSED_UNEXPECTEDLY: () => { + return { + default: ['chrome'], + } + }, UNEXPECTED_BEFORE_BROWSER_LAUNCH_PROPERTIES: () => { return { default: [ diff --git a/packages/extension/app/background.js b/packages/extension/app/background.js index b7ac7214971d..0dc29693170e 100644 --- a/packages/extension/app/background.js +++ b/packages/extension/app/background.js @@ -278,8 +278,6 @@ const automation = { resetBrowserTabsForNextTest (fn) { return Promise.try(() => { - return browser.tabs.create({ url: 'about:blank' }) - }).then(() => { return browser.windows.getCurrent({ populate: true }) }).then((windowInfo) => { return browser.tabs.remove(windowInfo.tabs.map((tab) => tab.id)) diff --git a/packages/extension/test/integration/background_spec.js b/packages/extension/test/integration/background_spec.js index 0c7ca3fe3fcb..0bb6f3555da4 100644 --- a/packages/extension/test/integration/background_spec.js +++ b/packages/extension/test/integration/background_spec.js @@ -829,7 +829,6 @@ describe('app/background', () => { describe('reset:browser:tabs:for:next:test', () => { beforeEach(() => { - sinon.stub(browser.tabs, 'create').withArgs({ url: 'about:blank' }) sinon.stub(browser.windows, 'getCurrent').withArgs({ populate: true }).resolves({ id: '10', tabs: [{ id: '1' }, { id: '2' }, { id: '3' }] }) sinon.stub(browser.tabs, 'remove').withArgs(['1', '2', '3']).resolves() }) @@ -839,7 +838,6 @@ describe('app/background', () => { expect(id).to.eq(123) expect(obj.response).to.be.undefined - expect(browser.tabs.create).to.be.called expect(browser.windows.getCurrent).to.be.called expect(browser.tabs.remove).to.be.called diff --git a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts index f28533184922..73e98200c18f 100644 --- a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts +++ b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts @@ -401,7 +401,7 @@ async function makeE2ETasks () { return { modeOptions, - e2eServerPort: ctx.appServerPort, + e2eServerPort: ctx.coreData.servers.appServerPort, } }, async __internal_openProject ({ argv, projectName }: InternalOpenProjectArgs): Promise { @@ -433,7 +433,7 @@ async function makeE2ETasks () { return { modeOptions, - e2eServerPort: ctx.appServerPort, + e2eServerPort: ctx.coreData.servers.appServerPort, } }, async __internal_withCtx (obj: WithCtxObj): Promise> { diff --git a/packages/frontend-shared/cypress/support/e2e.ts b/packages/frontend-shared/cypress/support/e2e.ts index aaf31afdccc4..5e8a3031ec51 100644 --- a/packages/frontend-shared/cypress/support/e2e.ts +++ b/packages/frontend-shared/cypress/support/e2e.ts @@ -334,7 +334,7 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e', options: { skipMocki }) } - return ctx.appServerPort + return ctx.coreData.servers.appServerPort }, { log: false, mode, url: win.top ? win.top.location.href : undefined, ...options }).then((serverPort) => { log?.set({ message: `port: ${serverPort}` }) Cypress.env('e2e_serverPort', serverPort) diff --git a/packages/frontend-shared/src/graphql/urqlClient.ts b/packages/frontend-shared/src/graphql/urqlClient.ts index 2f5cc12a7b41..22163a205a66 100644 --- a/packages/frontend-shared/src/graphql/urqlClient.ts +++ b/packages/frontend-shared/src/graphql/urqlClient.ts @@ -10,7 +10,7 @@ import { } from '@urql/core' import { devtoolsExchange } from '@urql/devtools' import { useToast } from 'vue-toastification' -import type { Socket } from '@packages/socket/lib/browser' +import type { SocketShape } from '@packages/socket/lib/types' import { client } from '@packages/socket/lib/browser' import { createClient as createWsClient } from 'graphql-ws' @@ -93,7 +93,7 @@ export function makeCacheExchange (schema: any = urqlSchema) { declare global { interface Window { - ws?: Socket + ws?: SocketShape /** * We can set this in onBeforeLoad in Cypress tests, allowing us * to use cy.intercept in tests that we need it diff --git a/packages/frontend-shared/src/graphql/urqlExchangePubsub.ts b/packages/frontend-shared/src/graphql/urqlExchangePubsub.ts index e69f5b6ee2c7..2cadb7bd5a35 100644 --- a/packages/frontend-shared/src/graphql/urqlExchangePubsub.ts +++ b/packages/frontend-shared/src/graphql/urqlExchangePubsub.ts @@ -1,9 +1,9 @@ import { pipe, tap } from 'wonka' import type { Exchange, Operation, OperationResult } from '@urql/core' -import type { Socket } from '@packages/socket/lib/browser' +import type { SocketShape } from '@packages/socket/lib/types' import type { DefinitionNode, DocumentNode, OperationDefinitionNode } from 'graphql' -export const pubSubExchange = (io: Socket): Exchange => { +export const pubSubExchange = (io: SocketShape): Exchange => { return ({ client, forward }) => { const watchedOperations = new Map() const observedOperations = new Map() diff --git a/packages/frontend-shared/src/graphql/urqlFetchSocketAdapter.ts b/packages/frontend-shared/src/graphql/urqlFetchSocketAdapter.ts index 82bd58e0ac7a..1db4ac9f551b 100644 --- a/packages/frontend-shared/src/graphql/urqlFetchSocketAdapter.ts +++ b/packages/frontend-shared/src/graphql/urqlFetchSocketAdapter.ts @@ -1,8 +1,8 @@ import _ from 'lodash' -import type { Socket } from '@packages/socket/lib/browser' +import type { SocketShape } from '@packages/socket/lib/types' import type { ClientOptions } from '@urql/core' -export const urqlFetchSocketAdapter = (io: Socket): ClientOptions['fetch'] => { +export const urqlFetchSocketAdapter = (io: SocketShape): ClientOptions['fetch'] => { return (url, fetchOptions = {}) => { return new Promise((resolve, reject) => { // Handle aborted requests diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 4068e7ea242d..b25af0988237 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -1115,6 +1115,8 @@ enum ErrorTypeEnum { BROWSER_CRASHED BROWSER_NOT_FOUND_BY_NAME BROWSER_NOT_FOUND_BY_PATH + BROWSER_PAGE_CLOSED_UNEXPECTEDLY + BROWSER_PROCESS_CLOSED_UNEXPECTEDLY BROWSER_UNSUPPORTED_LAUNCH_OPTION BUNDLE_ERROR CANNOT_CONNECT_BASE_URL diff --git a/packages/graphql/src/makeGraphQLServer.ts b/packages/graphql/src/makeGraphQLServer.ts index 84159139e3f8..cc1433e60e8a 100644 --- a/packages/graphql/src/makeGraphQLServer.ts +++ b/packages/graphql/src/makeGraphQLServer.ts @@ -25,8 +25,8 @@ let gqlSocketServer: SocketIONamespace let gqlServer: Server globalPubSub.on('reset:data-context', (ctx) => { - ctx.setGqlServer(gqlServer) - ctx.setGqlSocketServer(gqlSocketServer) + ctx.actions.servers.setGqlServer(gqlServer) + ctx.actions.servers.setGqlSocketServer(gqlSocketServer) }) export async function makeGraphQLServer () { @@ -85,12 +85,12 @@ export async function makeGraphQLServer () { app.get('/__launchpad/*', makeProxy()) + const ctx = getCtx() const graphqlPort = process.env.CYPRESS_INTERNAL_GRAPHQL_PORT let srv: Server function listenCallback () { - const ctx = getCtx() const port = (srv.address() as AddressInfo).port const endpoint = `http://localhost:${port}/__launchpad/graphql` @@ -104,7 +104,7 @@ export async function makeGraphQLServer () { gqlServer = srv - ctx.setGqlServer(srv) + ctx.actions.servers.setGqlServer(srv) dfd.resolve(port) } @@ -126,7 +126,7 @@ export async function makeGraphQLServer () { socket.on('graphql:request', handleGraphQLSocketRequest) }) - getCtx().setGqlSocketServer(gqlSocketServer) + ctx.actions.servers.setGqlSocketServer(gqlSocketServer) return dfd.promise } diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 97c3a01591fb..5aaf7253e0e1 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -21,7 +21,7 @@ export const mutation = mutationType({ text: nonNull(stringArg()), }, resolve: (_, { text }, ctx) => { - ctx.electronApi.copyTextToClipboard(text) + ctx.config.electronApi.copyTextToClipboard(text) return true }, @@ -215,7 +215,7 @@ export const mutation = mutationType({ // signal to launchpad to reload the data context ctx.emitter.toLaunchpad() - return ctx.wizardData + return ctx.coreData.wizard }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index c01c4c437536..04e3aa7ae97a 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -18,19 +18,19 @@ export const Query = objectType({ definition (t) { t.field('baseError', { type: ErrorWrapper, - resolve: (root, args, ctx) => ctx.baseError, + resolve: (root, args, ctx) => ctx.coreData.diagnostics.error, }) t.field('cachedUser', { type: CachedUser, - resolve: (root, args, ctx) => ctx.user, + resolve: (root, args, ctx) => ctx.coreData.user, }) t.nonNull.list.nonNull.field('warnings', { type: ErrorWrapper, description: 'A list of warnings', resolve: (source, args, ctx) => { - return ctx.warnings + return ctx.coreData.diagnostics.warnings }, }) @@ -87,7 +87,7 @@ export const Query = objectType({ t.nonNull.list.nonNull.field('projects', { type: ProjectLike, description: 'All known projects for the app', - resolve: (root, args, ctx) => ctx.appData.projects, + resolve: (root, args, ctx) => ctx.coreData.app.projects, }) t.nonNull.boolean('isGlobalMode', { @@ -127,7 +127,7 @@ export const Query = objectType({ name: nonNull(stringArg({ description: 'the name of the cohort to find' })), }, resolve: async (source, args, ctx) => { - return await ctx.cohortsApi.getCohort(args.name) ?? null + return await ctx.config.cohortsApi.getCohort(args.name) ?? null }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts index 02eac2e07465..4992db81cbed 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts @@ -149,7 +149,7 @@ export const Subscription = subscriptionType({ type: Wizard, description: 'Triggered when there is a change to the automatically-detected framework/bundler for a CT project', subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('frameworkDetectionChange', { sendInitial: false }), - resolve: (source, args, ctx) => ctx.wizardData, + resolve: (source, args, ctx) => ctx.coreData.wizard, }) }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-WizardFrontendFramework.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-WizardFrontendFramework.ts index d61c186f5ddb..b8b211cd27e4 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-WizardFrontendFramework.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-WizardFrontendFramework.ts @@ -1,7 +1,7 @@ -import { WizardBundler } from './gql-WizardBundler' import { objectType } from 'nexus' import { WIZARD_BUNDLERS } from '@packages/scaffold-config' import { SupportStatusEnum } from '../enumTypes' +import { WizardBundler } from './gql-WizardBundler' export const WizardFrontendFramework = objectType({ name: 'WizardFrontendFramework', @@ -27,12 +27,12 @@ export const WizardFrontendFramework = objectType({ t.nonNull.boolean('isSelected', { description: 'Whether this is the selected framework in the wizard', - resolve: (source, args, ctx) => ctx.wizardData.chosenFramework?.type === source.type, + resolve: (source, args, ctx) => ctx.coreData.wizard.chosenFramework?.type === source.type, }) t.nonNull.boolean('isDetected', { description: 'Whether this is the detected framework', - resolve: (source, args, ctx) => ctx.wizardData.detectedFramework?.type === source.type, + resolve: (source, args, ctx) => ctx.coreData.wizard.detectedFramework?.type === source.type, }) t.nonNull.list.nonNull.field('supportedBundlers', { @@ -49,7 +49,7 @@ export const WizardFrontendFramework = objectType({ return b } - return ctx.wizardData.chosenFramework?.supportedBundlers.map(findBundler) ?? [] + return ctx.coreData.wizard.chosenFramework?.supportedBundlers.map(findBundler) ?? [] }, }) diff --git a/packages/graphql/test/stubCloudTypes.ts b/packages/graphql/test/stubCloudTypes.ts index 2c85a301d4b5..a286f89a3d8e 100644 --- a/packages/graphql/test/stubCloudTypes.ts +++ b/packages/graphql/test/stubCloudTypes.ts @@ -542,10 +542,10 @@ export const CloudQuery: MaybeResolver = { }, cloudViewer (args, ctx) { if (ctx.__server__) { - return ctx.__server__.user ? { + return ctx.__server__.coreData.user ? { ...CloudUserStubs.me, - email: ctx.__server__.user.email, - fullName: ctx.__server__.user.name, + email: ctx.__server__.coreData.user.email, + fullName: ctx.__server__.coreData.user.name, } : null } diff --git a/packages/launchpad/cypress/e2e/choose-a-browser.cy.ts b/packages/launchpad/cypress/e2e/choose-a-browser.cy.ts index b5b9b4d429d6..d92814d60023 100644 --- a/packages/launchpad/cypress/e2e/choose-a-browser.cy.ts +++ b/packages/launchpad/cypress/e2e/choose-a-browser.cy.ts @@ -142,19 +142,19 @@ describe.skip('Choose a browser page', () => { }) cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('opening') + ctx.actions.app.setBrowserStatus('opening') }) cy.contains('button', 'Opening E2E Testing in Chrome') cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('open') + ctx.actions.app.setBrowserStatus('open') }) cy.contains('button', 'Running Chrome') cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('closed') + ctx.actions.app.setBrowserStatus('closed') }) cy.contains('button', 'Start E2E Testing in Chrome') @@ -207,7 +207,7 @@ describe.skip('Choose a browser page', () => { cy.visitLaunchpad() cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('open') + ctx.actions.app.setBrowserStatus('open') }) cy.contains('button', 'Running Chrome') @@ -224,7 +224,7 @@ describe.skip('Choose a browser page', () => { cy.visitLaunchpad() cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('open') + ctx.actions.app.setBrowserStatus('open') }) cy.get('h1').should('contain', 'Choose a browser') @@ -287,7 +287,7 @@ describe.skip('Choose a browser page', () => { cy.contains('button', 'Start E2E Testing in Chrome').should('be.visible').click() cy.withCtx((ctx) => { - ctx.browser.setBrowserStatus('open') + ctx.actions.app.setBrowserStatus('open') }) cy.contains('button', 'Running Chrome') @@ -296,7 +296,7 @@ describe.skip('Choose a browser page', () => { // both are reflected in the UI. cy.withCtx(async (ctx) => { await ctx.actions.browser.setActiveBrowser(ctx.lifecycleManager.browsers!.find((browser) => browser.name === 'firefox') as FoundBrowser) - ctx.browser.setBrowserStatus('closed') + ctx.actions.app.setBrowserStatus('closed') }) cy.contains('button', 'Start E2E Testing in Firefox').should('be.visible') diff --git a/packages/launchpad/cypress/e2e/open-mode.cy.ts b/packages/launchpad/cypress/e2e/open-mode.cy.ts index da5e8fb7ab02..b09ca685e9de 100644 --- a/packages/launchpad/cypress/e2e/open-mode.cy.ts +++ b/packages/launchpad/cypress/e2e/open-mode.cy.ts @@ -62,7 +62,7 @@ describe('Launchpad: Open Mode', () => { }) cy.withCtx((ctx, o) => { - ctx.localSettingsApi.setPreferences({ + ctx.config.localSettingsApi.setPreferences({ notifyWhenRunCompletes: ['failed'], }) }) diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index 361529c856c7..4d6d536731a6 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -1,3 +1,4 @@ +import Bluebird from 'bluebird' import CRI from 'chrome-remote-interface' import Debug from 'debug' import { _connectAsync, _getDelayMsForRetry } from './protocol' @@ -12,11 +13,6 @@ interface Version { minor: number } -// since we may be attempting to connect to multiple hosts, 'connected' -// is set to true once one of the connections succeeds so the others -// can be cancelled -let connected = false - const isVersionGte = (a: Version, b: Version) => { return a.major > b.major || (a.major === b.major && a.minor >= b.minor) } @@ -27,41 +23,68 @@ const getMajorMinorVersion = (version: string): Version => { return { major, minor } } -const tryBrowserConnection = async (host: string, port: number, browserName: string): Promise => { - const connectOpts = { - host, - port, - getDelayMsForRetry: (i) => { - // if we successfully connected to a different host, cancel any remaining connection attempts - if (connected) { - debug('cancelling any additional retries %o', { host, port }) - - return - } - - return _getDelayMsForRetry(i, browserName) - }, - } +const ensureLiveBrowser = async (hosts: string[], port: number, browserName: string): Promise => { + // since we may be attempting to connect to multiple hosts, 'connected' + // is set to true once one of the connections succeeds so the others + // can be cancelled + let connected = false + + const tryBrowserConnection = async (host: string, port: number, browserName: string): Promise => { + const connectOpts = { + host, + port, + getDelayMsForRetry: (i) => { + // if we successfully connected to a different host, cancel any remaining connection attempts + if (connected) { + debug('cancelling any additional retries %o', { host, port }) + + return + } + + return _getDelayMsForRetry(i, browserName) + }, + } - try { await _connectAsync(connectOpts) connected = true return host - } catch (err) { - // don't throw an error if we've already connected - if (!connected) { - debug('failed to connect to CDP %o', { connectOpts, err }) - errors.throwErr('CDP_COULD_NOT_CONNECT', browserName, port, err) - } - - return } -} -const ensureLiveBrowser = async (hosts: string[], port: number, browserName: string) => { + const connections = hosts.map((host) => { + return tryBrowserConnection(host, port, browserName) + .catch((err) => { + // don't throw an error if we've already connected + if (!connected) { + const e = errors.get('CDP_COULD_NOT_CONNECT', browserName, port, err) + + e.cause = { + err, + host, + port, + } + + throw e + } + + return '' + }) + }) + // go through all of the hosts and attempt to make a connection - return Promise.any(hosts.map((host) => tryBrowserConnection(host, port, browserName))) + return Promise.any(connections) + // this only fires if ALL of the connections fail + // otherwise if 1 succeeds and 1+ fails it won't log anything + .catch((aggErr: AggregateError) => { + aggErr.errors.forEach((e) => { + const { host, port, err } = e.cause + + debug('failed to connect to CDP %o', { host, port, err }) + }) + + // throw the first error we received from the aggregate + throw aggErr.errors[0] + }) } const retryWithIncreasingDelay = async (retryable: () => Promise, browserName: string, port: number): Promise => { @@ -92,7 +115,17 @@ const retryWithIncreasingDelay = async (retryable: () => Promise, browserN export class BrowserCriClient { currentlyAttachedTarget: CriClient | undefined - private constructor (private browserClient: CriClient, private versionInfo, public host: string, public port: number, private browserName: string, private onAsynchronousError: Function, private protocolManager?: ProtocolManagerShape) {} + // whenever we instantiate the instance we're already connected bc + // we receive an underlying CRI connection + // TODO: remove "connected" in favor of closing/closed or disconnected + connected = true + closing = false + closed = false + resettingBrowserTargets = false + gracefulShutdown?: Boolean + onClose: Function | null = null + + private constructor (private browserClient: CriClient, private versionInfo, public host: string, public port: number, private browserName: string, private onAsynchronousError: Function, private protocolManager?: ProtocolManagerShape) { } /** * Factory method for the browser cri client. Connects to the browser and then returns a chrome remote interface wrapper around the @@ -102,16 +135,98 @@ export class BrowserCriClient { * @param port the port to which to connect * @param browserName the display name of the browser being launched * @param onAsynchronousError callback for any cdp fatal errors + * @param onReconnect callback for when the browser cri client reconnects to the browser + * @param protocolManager the protocol manager to use with the browser cri client + * @param fullyManageTabs whether or not to fully manage tabs. This is useful for firefox where some work is done with marionette and some with CDP. We don't want to handle disconnections in this class in those scenarios * @returns a wrapper around the chrome remote interface that is connected to the browser target */ - static async create (hosts: string[], port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CriClient) => void, protocolManager?: ProtocolManagerShape): Promise { + static async create (hosts: string[], port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CriClient) => void, protocolManager?: ProtocolManagerShape, { fullyManageTabs }: { fullyManageTabs?: boolean } = {}): Promise { const host = await ensureLiveBrowser(hosts, port, browserName) return retryWithIncreasingDelay(async () => { const versionInfo = await CRI.Version({ host, port, useHostName: true }) const browserClient = await create(versionInfo.webSocketDebuggerUrl, onAsynchronousError, undefined, undefined, onReconnect) - return new BrowserCriClient(browserClient, versionInfo, host!, port, browserName, onAsynchronousError, protocolManager) + const browserCriClient = new BrowserCriClient(browserClient, versionInfo, host!, port, browserName, onAsynchronousError, protocolManager) + + if (fullyManageTabs) { + await browserClient.send('Target.setDiscoverTargets', { discover: true }) + + browserClient.on('Target.targetDestroyed', (event) => { + debug('Target.targetDestroyed %o', { + event, + closing: browserCriClient.closing, + closed: browserCriClient.closed, + resettingBrowserTargets: browserCriClient.resettingBrowserTargets, + }) + + // we may have gotten a delayed "Target.targetDestroyed" even for a page that we + // have already closed/disposed, so unless this matches our current target then bail + if (event.targetId !== browserCriClient.currentlyAttachedTarget?.targetId) { + return + } + + // otherwise... + // the page or browser closed in an unexpected manner and we need to bubble up this error + // by calling onError() with either browser or page was closed + // + // we detect this by waiting up to 500ms for either the browser's websocket connection to be closed + // OR from process.exit(...) firing + // if the browser's websocket connection has been closed then that means the page was closed + // + // otherwise it means the the browser itself was closed + + // always close the connection to the page target because it was destroyed + browserCriClient.currentlyAttachedTarget.close().catch(() => { }), + + new Bluebird((resolve) => { + // this event could fire either expectedly or unexpectedly + // it's not a problem if we're expected to be closing the browser naturally + // and not as a result of an unexpected page or browser closure + if (browserCriClient.resettingBrowserTargets) { + // do nothing, we're good + return resolve(true) + } + + if (typeof browserCriClient.gracefulShutdown !== 'undefined') { + return resolve(browserCriClient.gracefulShutdown) + } + + // when process.on('exit') is called, we call onClose + browserCriClient.onClose = resolve + + // or when the browser's CDP ws connection is closed + browserClient.ws.once('close', () => { + resolve(false) + }) + }) + .timeout(500) + .then((expectedDestroyedEvent) => { + if (expectedDestroyedEvent === true) { + return + } + + // browserClient websocket was disconnected + // or we've been closed due to process.on('exit') + // meaning the browser was closed and not just the page + errors.throwErr('BROWSER_PROCESS_CLOSED_UNEXPECTEDLY', browserName) + }) + .catch(Bluebird.TimeoutError, () => { + debug('browser websocket did not close, page was closed %o', { targetId: event.targetId }) + // the browser websocket didn't close meaning + // only the page was closed, not the browser + errors.throwErr('BROWSER_PAGE_CLOSED_UNEXPECTEDLY', browserName) + }) + .catch((err) => { + // stop the run instead of moving to the next spec + err.isFatalApiErr = true + + onAsynchronousError(err) + }) + }) + } + + return browserCriClient }, browserName, port) } @@ -163,6 +278,14 @@ export class BrowserCriClient { * @param shouldKeepTabOpen whether or not to keep the tab open */ resetBrowserTargets = async (shouldKeepTabOpen: boolean): Promise => { + if (this.closed) { + debug('browser cri client is closed, not resetting browser targets') + + return + } + + this.resettingBrowserTargets = true + if (!this.currentlyAttachedTarget) { throw new Error('Cannot close target because no target is currently attached') } @@ -174,28 +297,52 @@ export class BrowserCriClient { target = await this.browserClient.send('Target.createTarget', { url: 'about:blank' }) } - debug('Closing current target %s', this.currentlyAttachedTarget.targetId) + debug('currently attached targets', this.currentlyAttachedTarget.targetId, this.currentlyAttachedTarget.closed) + + if (!this.currentlyAttachedTarget.closed) { + debug('closing current target %s', this.currentlyAttachedTarget.targetId) - await Promise.all([ - // If this fails, it shouldn't prevent us from continuing - this.currentlyAttachedTarget.close().catch(), - this.browserClient.send('Target.closeTarget', { targetId: this.currentlyAttachedTarget.targetId }), - ]) + await this.browserClient.send('Target.closeTarget', { targetId: this.currentlyAttachedTarget.targetId }) + + debug('target closed', this.currentlyAttachedTarget.targetId) + + await this.currentlyAttachedTarget.close().catch(() => {}) + + debug('target client closed', this.currentlyAttachedTarget.targetId) + } if (target) { this.currentlyAttachedTarget = await create(target.targetId, this.onAsynchronousError, this.host, this.port) + } else { + this.currentlyAttachedTarget = undefined } + + this.resettingBrowserTargets = false } /** * Closes the browser client socket as well as the socket for the currently attached page target */ - close = async () => { + close = async (gracefulShutdown) => { + this.gracefulShutdown = gracefulShutdown + + this.onClose && this.onClose(gracefulShutdown) + + if (this.connected === false) { + debug('browser cri client is already closed') + + return + } + + this.closing = true + this.connected = false + if (this.currentlyAttachedTarget) { await this.currentlyAttachedTarget.close() } - connected = false await this.browserClient.close() + + this.closed = true } } diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 5d176311ce7e..45bfc30184a4 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -143,6 +143,8 @@ export type SendDebuggerCommand = (message: T, data?: Prot export type OnFn = (eventName: T, cb: (data: ProtocolMapping.Events[T][0]) => void) => void +export type OffFn = (eventName: string, cb: (data: any) => void) => void + type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise | void interface HasFrame { frame: Protocol.Page.Frame @@ -160,16 +162,18 @@ const ffToStandardResourceTypeMap: { [ff: string]: ResourceType } = { export class CdpAutomation implements CDPClient { on: OnFn + off: OffFn send: SendDebuggerCommand private frameTree: any private gettingFrameTree: any - private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation) { + private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private offFn: OffFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation) { onFn('Network.requestWillBeSent', this.onNetworkRequestWillBeSent) onFn('Network.responseReceived', this.onResponseReceived) onFn('Network.requestServedFromCache', this.onRequestServedFromCache) this.on = onFn + this.off = offFn this.send = sendDebuggerCommandFn } @@ -189,8 +193,8 @@ export class CdpAutomation implements CDPClient { await this.sendDebuggerCommandFn('Page.startScreencast', screencastOpts) } - static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, protocolManager?: ProtocolManagerShape): Promise { - const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, sendCloseCommandFn, automation) + static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, offFn: OffFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, protocolManager?: ProtocolManagerShape): Promise { + const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, offFn, sendCloseCommandFn, automation) const networkEnabledOptions = protocolManager?.protocolEnabled ? { maxTotalBufferSize: 0, diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index ec82f59e37b7..5b0424ebc352 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -15,13 +15,15 @@ import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' import * as errors from '../errors' -import type { Browser, BrowserInstance } from './types' import { BrowserCriClient } from './browser-cri-client' +import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types' import type { CriClient } from './cri-client' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' import memory from './memory' +import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' + const debug = debugModule('cypress:server:browsers:chrome') const LOAD_EXTENSION = '--load-extension=' @@ -312,7 +314,7 @@ const _handleDownloads = async function (client, downloadsFolder: string, automa let onReconnect: (client: CriClient) => Promise = async () => undefined const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: BrowserLaunchOpts) => { - const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, options.protocolManager) + const cdpAutomation = await CdpAutomation.create(client.send, client.on, client.off, resetBrowserTargets, automation, options.protocolManager) automation.use(cdpAutomation) @@ -436,10 +438,10 @@ export = { /** * Clear instance state for the chrome instance, this is normally called in on kill or on exit. */ - clearInstanceState (protocolManager?: ProtocolManagerShape) { - debug('closing remote interface client') + clearInstanceState (options: GracefulShutdownOptions = {}) { + debug('closing remote interface client', { options }) // Do nothing on failure here since we're shutting down anyway - browserCriClient?.close().catch() + browserCriClient?.close(options.gracefulShutdown).catch(() => {}) browserCriClient = undefined }, @@ -451,7 +453,7 @@ export = { await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedTarget) }, - async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation, socketServer?: CDPSocketServer) { debug('connecting to new chrome tab in existing instance with url and debugging port', { url: options.url }) const browserCriClient = this._getBrowserCriClient() @@ -465,31 +467,34 @@ export = { if (!options.url) throw new Error('Missing url in connectToNewSpec') await this.connectProtocolToBrowser({ protocolManager: options.protocolManager }) + await socketServer?.attachCDPClient(pageCriClient) - await this.attachListeners(options.url, pageCriClient, automation, options) + await this.attachListeners(options.url, pageCriClient, automation, options, browser) }, - async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: CDPSocketServer) { const port = await protocol.getRemoteDebuggingPort() debug('connecting to existing chrome instance with url and debugging port', { url: options.url, port }) if (!options.onError) throw new Error('Missing onError in connectToExisting') - const browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, browser.displayName, options.onError, onReconnect) + const browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, browser.displayName, options.onError, onReconnect, undefined, { fullyManageTabs: false }) if (!options.url) throw new Error('Missing url in connectToExisting') const pageCriClient = await browserCriClient.attachToTargetUrl(options.url) + await cdpSocketServer?.attachCDPClient(pageCriClient) + await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) }, - async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { + async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts, browser: Browser) { const browserCriClient = this._getBrowserCriClient() // Handle chrome tab crashes. pageCriClient.on('Inspector.targetCrashed', async () => { - const err = errors.get('RENDERER_CRASHED') + const err = errors.get('RENDERER_CRASHED', browser.displayName) await memory.endProfiling() @@ -532,7 +537,7 @@ export = { return cdpAutomation }, - async open (browser: Browser, url, options: BrowserLaunchOpts, automation: Automation): Promise { + async open (browser: Browser, url, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: CDPSocketServer): Promise { const { isTextTerminal } = options const userDir = utils.getProfileDir(browser, isTextTerminal) @@ -567,6 +572,8 @@ export = { ), _removeRootExtension(), _disableRestorePagesPrompt(userDir), + // Chrome adds a lock file to the user data dir. If we are restarting the run and browser, we need to remove it. + fs.unlink(path.join(userDir, 'SingletonLock')).catch(() => {}), _writeChromePreferences(userDir, preferences, launchOptions.preferences as ChromePreferences), ]) // normalize the --load-extensions argument by @@ -578,7 +585,7 @@ export = { args.push(`--user-data-dir=${userDir}`) args.push(`--disk-cache-dir=${cacheDir}`) - debug('launching in chrome with debugging port', { url, args, port }) + debug('launching in chrome with debugging port %o', { url, args, port }) // FIRST load the blank page // first allows us to connect the remote interface, @@ -593,7 +600,7 @@ export = { // navigate to the actual url if (!options.onError) throw new Error('Missing onError in chrome#open') - browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, browser.displayName, options.onError, onReconnect, options.protocolManager) + browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, browser.displayName, options.onError, onReconnect, options.protocolManager, { fullyManageTabs: true }) la(browserCriClient, 'expected Chrome remote interface reference', browserCriClient) @@ -612,7 +619,7 @@ export = { launchedBrowser.browserCriClient = browserCriClient launchedBrowser.kill = (...args) => { - this.clearInstanceState(options.protocolManager) + this.clearInstanceState({ gracefulShutdown: true }) debug('closing chrome') @@ -621,7 +628,9 @@ export = { const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - await this.attachListeners(url, pageCriClient, automation, options) + await cdpSocketServer?.attachCDPClient(pageCriClient) + + await this.attachListeners(url, pageCriClient, automation, options, browser) // return the launched browser process // with additional method to close the remote connection diff --git a/packages/server/lib/browsers/cri-client.ts b/packages/server/lib/browsers/cri-client.ts index f9475feb9d0b..70f931b4856c 100644 --- a/packages/server/lib/browsers/cri-client.ts +++ b/packages/server/lib/browsers/cri-client.ts @@ -1,7 +1,12 @@ +import CRI from 'chrome-remote-interface' import debugModule from 'debug' import _ from 'lodash' -import CRI from 'chrome-remote-interface' import * as errors from '../errors' + +import type EventEmitter from 'events' +import type WebSocket from 'ws' +import type CDP from 'chrome-remote-interface' + import type { SendDebuggerCommand, OnFn, CdpCommand, CdpEvent } from './cdp_automation' const debug = debugModule('cypress:server:browsers:cri-client') @@ -12,11 +17,42 @@ const debugVerboseReceive = debugModule('cypress-verbose:server:browsers:cri-cli const WEBSOCKET_NOT_OPEN_RE = /^WebSocket is (?:not open|already in CLOSING or CLOSED state)/ +type QueuedMessages = { + enableCommands: EnableCommand[] + enqueuedCommands: EnqueuedCommand[] + subscriptions: Subscription[] +} + +type EnqueuedCommand = { + command: CdpCommand + params?: object + p: DeferredPromise +} + +type EnableCommand = { + command: CdpCommand + params?: object +} + +type Subscription = { + eventName: CdpEvent + cb: Function +} + +interface CDPClient extends CDP.Client { + off: EventEmitter['off'] + _ws: WebSocket +} + export interface CriClient { /** * The target id attached to by this client */ targetId: string + /** + * The underlying websocket connection + */ + ws: CDPClient['_ws'] /** * Sends a command to the Chrome remote interface. * @example client.send('Page.navigate', { url }) @@ -31,11 +67,30 @@ export interface CriClient { * Calls underlying remote interface client close */ close (): Promise + + onReconnectAttempt? (retryIndex: number): void + + /** + * The internal queue of replayable messages that run after a disconnect + */ + queue: QueuedMessages + /** + * Whether this client has been closed + */ + closed: boolean + /** + * Whether this client is currently connected + */ + connected: boolean + /** + * Unregisters callback for particular event. + */ + off (eventName: string, cb: (event: any) => void): void } -const maybeDebugCdpMessages = (cri) => { +const maybeDebugCdpMessages = (cri: CDPClient) => { if (debugVerboseReceive.enabled) { - cri._ws.on('message', (data) => { + cri._ws.prependListener('message', (data) => { data = _ .chain(JSON.parse(data)) .tap((data) => { @@ -76,66 +131,102 @@ const maybeDebugCdpMessages = (cri) => { type DeferredPromise = { resolve: Function, reject: Function } -export const create = async (target: string, onAsynchronousError: Function, host?: string, port?: number, onReconnect?: (client: CriClient) => void): Promise => { - const subscriptions: {eventName: CdpEvent, cb: Function}[] = [] - const enableCommands: CdpCommand[] = [] - let enqueuedCommands: {command: CdpCommand, params: any, p: DeferredPromise }[] = [] +export const create = async ( + target: string, + onAsynchronousError: Function, + host?: string, + port?: number, + onReconnect?: (client: CriClient) => void, +): Promise => { + const subscriptions: Subscription[] = [] + const enableCommands: EnableCommand[] = [] + let enqueuedCommands: EnqueuedCommand[] = [] let closed = false // has the user called .close on this? let connected = false // is this currently connected to CDP? - let cri + let cri: CDPClient let client: CriClient - const reconnect = async () => { - debug('disconnected, attempting to reconnect... %o', { closed }) - + const reconnect = async (retryIndex) => { connected = false if (closed) { + debug('disconnected, not reconnecting because client is closed %o', { closed, target }) enqueuedCommands = [] return } - try { - await connect() + client.onReconnectAttempt?.(retryIndex) - debug('restoring subscriptions + running *.enable and queued commands... %o', { subscriptions, enableCommands, enqueuedCommands }) + debug('disconnected, attempting to reconnect... %o', { retryIndex, closed, target }) - // '*.enable' commands need to be resent on reconnect or any events in - // that namespace will no longer be received - await Promise.all(enableCommands.map((cmdName) => { - return cri.send(cmdName) - })) + await connect() - subscriptions.forEach((sub) => { - cri.on(sub.eventName, sub.cb) - }) + debug('restoring subscriptions + running *.enable and queued commands... %o', { subscriptions, enableCommands, enqueuedCommands, target }) - enqueuedCommands.forEach((cmd) => { - cri.send(cmd.command, cmd.params) - .then(cmd.p.resolve, cmd.p.reject) - }) + subscriptions.forEach((sub) => { + cri.on(sub.eventName, sub.cb as any) + }) - enqueuedCommands = [] + // '*.enable' commands need to be resent on reconnect or any events in + // that namespace will no longer be received + await Promise.all(enableCommands.map(({ command, params }) => { + return cri.send(command, params) + })) - if (onReconnect) { - onReconnect(client) - } - } catch (err) { - const cdpError = errors.get('CDP_COULD_NOT_RECONNECT', err) + enqueuedCommands.forEach((cmd) => { + cri.send(cmd.command, cmd.params).then(cmd.p.resolve as any, cmd.p.reject as any) + }) + + enqueuedCommands = [] - // If we cannot reconnect to CDP, we will be unable to move to the next set of specs since we use CDP to clean up and close tabs. Marking this as fatal - cdpError.isFatalApiErr = true - onAsynchronousError(cdpError) + if (onReconnect) { + onReconnect(client) } } + const retryReconnect = async () => { + debug('disconnected, starting retries to reconnect... %o', { closed, target }) + + const retry = async (retryIndex = 0) => { + retryIndex++ + + try { + return await reconnect(retryIndex) + } catch (err) { + if (closed) { + debug('could not reconnect because client is closed %o', { closed, target }) + + enqueuedCommands = [] + + return + } + + debug('could not reconnect, retrying... %o', { closed, target, err }) + + if (retryIndex < 20) { + await new Promise((resolve) => setTimeout(resolve, 100)) + + return retry(retryIndex) + } + + const cdpError = errors.get('CDP_COULD_NOT_RECONNECT', err) + + // If we cannot reconnect to CDP, we will be unable to move to the next set of specs since we use CDP to clean up and close tabs. Marking this as fatal + cdpError.isFatalApiErr = true + onAsynchronousError(cdpError) + } + } + + return retry() + } + const connect = async () => { await cri?.close() - debug('connecting %o', { target }) + debug('connecting %o', { connected, target }) cri = await CRI({ host, @@ -143,31 +234,55 @@ export const create = async (target: string, onAsynchronousError: Function, host target, local: true, useHostName: true, - }) + }) as CDPClient connected = true + debug('connected %o', { connected, target }) + maybeDebugCdpMessages(cri) - // @see https://github.com/cyrus-and/chrome-remote-interface/issues/72 - cri._notifier.on('disconnect', reconnect) + // Only reconnect when we're not running cypress in cypress. There are a lot of disconnects that happen that we don't want to reconnect on + if (!process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { + cri.on('disconnect', retryReconnect) + } } await connect() client = { targetId: target, + async send (command: CdpCommand, params?: object) { const enqueue = () => { + debug('enqueing command', { command, params }) + return new Promise((resolve, reject) => { - enqueuedCommands.push({ command, params, p: { resolve, reject } }) + const obj: EnqueuedCommand = { + command, + p: { resolve, reject } as DeferredPromise, + } + + if (params) { + obj.params = params + } + + enqueuedCommands.push(obj) }) } // Keep track of '*.enable' commands so they can be resent when // reconnecting - if (command.endsWith('.enable')) { - enableCommands.push(command) + if (command.endsWith('.enable') || ['Runtime.addBinding', 'Target.setDiscoverTargets'].includes(command)) { + const obj: EnableCommand = { + command, + } + + if (params) { + obj.params = params + } + + enableCommands.push(obj) } if (connected) { @@ -184,12 +299,14 @@ export const create = async (target: string, onAsynchronousError: Function, host debug('encountered closed websocket on send %o', { command, params, err }) - const p = enqueue() + const p = enqueue() as Promise - await reconnect() + await retryReconnect() // if enqueued commands were wiped out from the reconnect and the socket is already closed, reject the command as it will never be run if (enqueuedCommands.length === 0 && closed) { + debug('connection was closed was trying to reconnect') + return Promise.reject(new Error(`${command} will not run as browser CRI connection was reset`)) } @@ -199,16 +316,57 @@ export const create = async (target: string, onAsynchronousError: Function, host return enqueue() }, + on (eventName, cb) { subscriptions.push({ eventName, cb }) debug('registering CDP on event %o', { eventName }) return cri.on(eventName, cb) }, - close () { + + off (eventName, cb) { + subscriptions.splice(subscriptions.findIndex((sub) => { + return sub.eventName === eventName && sub.cb === cb + }), 1) + + return cri.off(eventName, cb) + }, + + get ws () { + return cri._ws + }, + + get queue () { + return { + enableCommands, + enqueuedCommands, + subscriptions, + } + }, + + get closed () { + return closed + }, + + get connected () { + return connected + }, + + async close () { + if (closed) { + debug('not closing, cri client is already closed %o', { closed, target }) + + return + } + + debug('closing cri client %o', { closed, target }) + closed = true return cri.close() + .finally(() => { + debug('closed cri client %o', { closed, target }) + }) }, } diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index b73ddc768485..b105e6e20954 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -8,10 +8,11 @@ import { CdpAutomation, screencastOpts } from './cdp_automation' import * as savedState from '../saved_state' import utils from './utils' import * as errors from '../errors' -import type { Browser, BrowserInstance } from './types' +import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types' import type { BrowserWindow } from 'electron' import type { Automation } from '../automation' import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import memory from './memory' import { BrowserCriClient } from './browser-cri-client' import { getRemoteDebuggingPort } from '../util/electron-app' @@ -53,16 +54,22 @@ const _getAutomation = async function (win, options: BrowserLaunchOpts, parent) const port = getRemoteDebuggingPort() if (!browserCriClient) { - browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, 'electron', options.onError, () => {}) + browserCriClient = await BrowserCriClient.create(['127.0.0.1'], port, 'electron', options.onError, () => {}, undefined, { fullyManageTabs: true }) } const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - const sendClose = () => { + const sendClose = async () => { + if (browserCriClient) { + const gracefulShutdown = true + + await browserCriClient.close(gracefulShutdown) + } + win.destroy() } - const automation = await CdpAutomation.create(pageCriClient.send, pageCriClient.on, sendClose, parent) + const automation = await CdpAutomation.create(pageCriClient.send, pageCriClient.on, pageCriClient.off, sendClose, parent) automation.onRequest = _.wrap(automation.onRequest, async (fn, message, data) => { switch (message) { @@ -142,7 +149,7 @@ export = { // causing screenshots/videos to be off by 1px resizable: !options.browser.isHeadless, async onCrashed () { - const err = errors.get('RENDERER_CRASHED') + const err = errors.get('RENDERER_CRASHED', 'Electron') await memory.endProfiling() @@ -202,7 +209,7 @@ export = { _getAutomation, - async _render (url: string, automation: Automation, preferences, options: ElectronOpts) { + async _render (url: string, automation: Automation, preferences, options: ElectronOpts, cdpSocketServer?: any) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -215,7 +222,7 @@ export = { win.maximize() } - return await this._launch(win, url, automation, preferences, options.videoApi, options.protocolManager) + return await this._launch(win, url, automation, preferences, options.videoApi, options.protocolManager, cdpSocketServer) }, _launchChild (url, parent, projectRoot, state, options, automation) { @@ -238,7 +245,7 @@ export = { return this._launch(win, url, automation, electronOptions) }, - async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape, cdpSocketServer?: CDPSocketServer) { if (options.show) { menu.set({ withInternalDevTools: true }) } @@ -250,10 +257,15 @@ export = { }) }) - await win.loadURL('about:blank') - const cdpAutomation = await this._getAutomation(win, options, automation) + let cdpAutomation - automation.use(cdpAutomation) + // If the cdp socket server is not present, this is a child window and we don't want to bind or listen to anything + if (cdpSocketServer) { + await win.loadURL('about:blank') + cdpAutomation = await this._getAutomation(win, options, automation) + + automation.use(cdpAutomation) + } const ua = options.userAgent @@ -283,26 +295,32 @@ export = { this._clearCache(win.webContents), ]) - const browserCriClient = this._getBrowserCriClient() - const pageCriClient = browserCriClient?.currentlyAttachedTarget - - if (!pageCriClient) throw new Error('Missing pageCriClient in _launch') + if (cdpAutomation) { + const browserCriClient = this._getBrowserCriClient() + const pageCriClient = browserCriClient?.currentlyAttachedTarget - await pageCriClient.send('Page.enable') + if (!pageCriClient) throw new Error('Missing pageCriClient in _launch') - await Promise.all([ - this.connectProtocolToBrowser({ protocolManager }), - videoApi && recordVideo(cdpAutomation, videoApi), - this._handleDownloads(win, options.downloadsFolder, automation), - ]) + await Promise.all([ + pageCriClient.send('Page.enable'), + this.connectProtocolToBrowser({ protocolManager }), + cdpSocketServer?.attachCDPClient(cdpAutomation), + videoApi && recordVideo(cdpAutomation, videoApi), + this._handleDownloads(win, options.downloadsFolder, automation), + ]) + } // enabling can only happen once the window has loaded await this._enableDebugger() - await win.loadURL(url) + // Note that these calls have to happen before we load the page so that we don't miss out on any events that happen quickly + if (cdpAutomation) { + // These calls need to happen prior to loading the URL so we can be sure to get the frames as they come in + await cdpAutomation._handlePausedRequests(browserCriClient?.currentlyAttachedTarget) + cdpAutomation._listenForFrameTreeChanges(browserCriClient?.currentlyAttachedTarget) + } - await cdpAutomation._handlePausedRequests(pageCriClient) - cdpAutomation._listenForFrameTreeChanges(pageCriClient) + await win.loadURL(url) return win }, @@ -405,9 +423,10 @@ export = { /** * Clear instance state for the electron instance, this is normally called on kill or on exit, for electron there isn't any state to clear. */ - clearInstanceState () { + clearInstanceState (options: GracefulShutdownOptions = {}) { + debug('closing remote interface client', { options }) // Do nothing on failure here since we're shutting down anyway - browserCriClient?.close().catch() + browserCriClient?.close(options.gracefulShutdown).catch(() => {}) browserCriClient = null }, @@ -439,7 +458,7 @@ export = { } }, - async open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) { + async open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: any) { debug('open %o', { browser, url }) const State = await savedState.create(options.projectRoot, options.isTextTerminal) @@ -466,7 +485,7 @@ export = { debug('launching browser window to url: %s', url) - const win = await this._render(url, automation, preferences, electronOptions) + const win = await this._render(url, automation, preferences, electronOptions, cdpSocketServer) await _installExtensions(win, launchOptions.extensions, electronOptions) @@ -496,7 +515,7 @@ export = { allPids: [mainPid], browserWindow: win, kill (this: BrowserInstance) { - clearInstanceState() + clearInstanceState({ gracefulShutdown: true }) if (this.isProcessExit) { // if the process is exiting, all BrowserWindows will be destroyed anyways diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index c1b83a99e049..34c6b5d640db 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -126,9 +126,10 @@ async function connectToNewSpec (options, automation: Automation, browserCriClie debug('firefox: reconnecting CDP') + await browserCriClient.currentlyAttachedTarget?.close().catch(() => {}) const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - await CdpAutomation.create(pageCriClient.send, pageCriClient.on, browserCriClient.resetBrowserTargets, automation) + await CdpAutomation.create(pageCriClient.send, pageCriClient.on, pageCriClient.off, browserCriClient.resetBrowserTargets, automation) await options.onInitializeNewBrowserTab() @@ -140,7 +141,7 @@ async function setupRemote (remotePort, automation, onError): Promise { // Close browser cri client socket. Do nothing on failure here since we're shutting down anyway if (browserCriClient) { - browserCriClient.close().catch() - browserCriClient = undefined + clearInstanceState({ gracefulShutdown: true }) } treeKill(browserInstance.pid as number, (err?, result?) => { @@ -374,10 +373,10 @@ export function _createDetachedInstance (browserInstance: BrowserInstance, brows /** * Clear instance state for the chrome instance, this is normally called in on kill or on exit. */ -export function clearInstanceState () { +export function clearInstanceState (options: GracefulShutdownOptions = {}) { debug('closing remote interface client') if (browserCriClient) { - browserCriClient.close().catch() + browserCriClient.close(options.gracefulShutdown).catch(() => {}) browserCriClient = undefined } } @@ -568,7 +567,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc browserInstance.kill = (...args) => { // Do nothing on failure here since we're shutting down anyway - clearInstanceState() + clearInstanceState({ gracefulShutdown: true }) debug('closing firefox') diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index d1ad373bf5d5..e6be06b2b93e 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -10,6 +10,8 @@ import os from 'os' import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape } from '@packages/types' import type { Browser, BrowserInstance, BrowserLauncher } from './types' import type { Automation } from '../automation' +import type { DataContext } from '@packages/data-context' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' const debug = Debug('cypress:server:browsers') const isBrowserFamily = check.oneOf(BROWSER_FAMILY) @@ -129,10 +131,10 @@ export = { return instance }, - async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation): Promise { + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: CDPSocketServer): Promise { const browserLauncher = await getBrowserLauncher(browser, options.browsers) - await browserLauncher.connectToExisting(browser, options, automation) + await browserLauncher.connectToExisting(browser, options, automation, cdpSocketServer) return this.getBrowserInstance() }, @@ -143,15 +145,15 @@ export = { await browserLauncher.connectProtocolToBrowser(options) }, - async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation): Promise { + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation, cdpSocketServer?: CDPSocketServer): Promise { const browserLauncher = await getBrowserLauncher(browser, options.browsers) - await browserLauncher.connectToNewSpec(browser, options, automation) + await browserLauncher.connectToNewSpec(browser, options, automation, cdpSocketServer) return this.getBrowserInstance() }, - async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx): Promise { + async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx: DataContext): Promise { // this global helps keep track of which launch attempt is the latest one launchAttempt++ @@ -168,7 +170,7 @@ export = { onBrowserClose () {}, }) - ctx.browser.setBrowserStatus('opening') + ctx.actions.app.setBrowserStatus('opening') const browserLauncher = await getBrowserLauncher(browser, options.browsers) @@ -176,7 +178,7 @@ export = { debug('opening browser %o', browser) - const _instance = await browserLauncher.open(browser, options.url, options, automation) + const _instance = await browserLauncher.open(browser, options.url, options, automation, ctx.coreData.servers.cdpSocketServer) debug('browser opened') @@ -215,7 +217,9 @@ export = { // so that there is a default for each browser but // enable the browser to configure the interface instance.once('exit', async (code, signal) => { - ctx.browser.setBrowserStatus('closed') + debug('browser instance exit event received %o', { code, signal }) + + ctx.actions.app.setBrowserStatus('closed') // TODO: make this a required property if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode') @@ -265,7 +269,7 @@ export = { if (!options.onBrowserOpen) throw new Error('onBrowserOpen did not exist in interactive mode') options.onBrowserOpen() - ctx.browser.setBrowserStatus('open') + ctx.actions.app.setBrowserStatus('open') return instance }, diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index 1cc8205edf7c..3d3b3ec69c4f 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -1,6 +1,7 @@ import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape } from '@packages/types' import type { EventEmitter } from 'events' import type { Automation } from '../automation' +import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' export type Browser = FoundBrowser & { majorVersion: number @@ -29,12 +30,12 @@ export type BrowserInstance = EventEmitter & { } export type BrowserLauncher = { - open: (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) => Promise - connectToNewSpec: (browser: Browser, options: BrowserNewTabOpts, automation: Automation) => Promise + open: (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: CDPSocketServer) => Promise + connectToNewSpec: (browser: Browser, options: BrowserNewTabOpts, automation: Automation, cdpSocketServer?: CDPSocketServer) => Promise /** * Used in Cypress-in-Cypress tests to connect to the existing browser instance. */ - connectToExisting: (browser: Browser, options: BrowserLaunchOpts, automation: Automation) => void | Promise + connectToExisting: (browser: Browser, options: BrowserLaunchOpts, automation: Automation, cdpSocketServer?: CDPSocketServer) => void | Promise /** * Used to clear instance state after the browser has been exited. */ @@ -44,3 +45,7 @@ export type BrowserLauncher = { */ connectProtocolToBrowser: (options: { protocolManager?: ProtocolManagerShape }) => Promise } + +export type GracefulShutdownOptions = { + gracefulShutdown?: boolean +} diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 8a7d76d4cfbd..a02540daba39 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -129,8 +129,8 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc async kill () { debug('closing pwBrowser') - await pwBrowser.close() clearInstanceState() + await pwBrowser.close() } /** diff --git a/packages/server/lib/modes/interactive.ts b/packages/server/lib/modes/interactive.ts index b76912ca5dd3..b9504a08e6c6 100644 --- a/packages/server/lib/modes/interactive.ts +++ b/packages/server/lib/modes/interactive.ts @@ -142,7 +142,7 @@ export = { return globalPubSub.emit('menu:item:clicked', 'log:out') }, getGraphQLPort: () => { - return ctx?.gqlServerPort + return ctx?.coreData.servers.gqlServerPort }, }) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index b0c9b0781b41..7242d742c921 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -171,7 +171,7 @@ export class OpenProject { // TODO: Stub this so we can detect it being called if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { - return await browsers.connectToExisting(browser, options, automation) + return await browsers.connectToExisting(browser, options, automation, this._ctx?.coreData.servers.cdpSocketServer) } // if we should launch a new tab and we are not running in electron (which does not support connecting to a new spec) @@ -184,12 +184,12 @@ export class OpenProject { // If we do not launch the browser, // we tell it that we are ready // to receive the next spec - return await browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation) + return await browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation, this._ctx?.coreData.servers.cdpSocketServer) } options.relaunchBrowser = this.relaunchBrowser - return await browsers.open(browser, options, automation, this._ctx) + return await browsers.open(browser, options, automation, this._ctx!) } return this.relaunchBrowser() diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index e1bbcb21d8bb..4d5487951fc7 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -161,7 +161,7 @@ export class ProjectBase extends EE { SocketCtor: this.testingType === 'e2e' ? SocketE2E : SocketCt, }) - this.ctx.setAppServerPort(port) + this.ctx.actions.servers.setAppServerPort(port) this._isServerOpen = true // if we didnt have a cfg.port @@ -270,8 +270,8 @@ export class ProjectBase extends EE { this.__reset() - this.ctx.setAppServerPort(undefined) - this.ctx.setAppSocketServer(undefined) + this.ctx.actions.servers.setAppServerPort(undefined) + this.ctx.actions.servers.setAppSocketServer(undefined) await Promise.all([ this.server?.close(), @@ -336,7 +336,7 @@ export class ProjectBase extends EE { this._automation = new Automation(namespace, socketIoCookie, screenshotsFolder, onBrowserPreRequest, onRequestEvent, onRequestServedFromCache) - const io = this.server.startWebsockets(this.automation, this.cfg, { + const ios = this.server.startWebsockets(this.automation, this.cfg, { onReloadBrowser: options.onReloadBrowser, onFocusTests: options.onFocusTests, onSpecChanged: options.onSpecChanged, @@ -394,7 +394,7 @@ export class ProjectBase extends EE { }, }) - this.ctx.setAppSocketServer(io) + this.ctx.actions.servers.setAppSocketServer(ios) } async resetBrowserTabsForNextTest (shouldKeepTabOpen: boolean) { diff --git a/packages/server/lib/request.js b/packages/server/lib/request.js index 28ec6adb3d26..f1a2d7c25d8d 100644 --- a/packages/server/lib/request.js +++ b/packages/server/lib/request.js @@ -606,9 +606,7 @@ module.exports = function (options = {}) { }) }, - sendStream (headers, automationFn, options = {}) { - let ua - + sendStream (userAgent, automationFn, options = {}) { _.defaults(options, { headers: {}, followAllRedirects: true, @@ -617,8 +615,8 @@ module.exports = function (options = {}) { }, }) - if (!caseInsensitiveGet(options.headers, 'user-agent') && (ua = headers['user-agent'])) { - options.headers['user-agent'] = ua + if (!caseInsensitiveGet(options.headers, 'user-agent') && userAgent) { + options.headers['user-agent'] = userAgent } _.extend(options, { @@ -664,9 +662,7 @@ module.exports = function (options = {}) { }) }, - sendPromise (headers, automationFn, options = {}) { - let a; let c; let ua - + sendPromise (userAgent, automationFn, options = {}) { _.defaults(options, { headers: {}, gzip: true, @@ -674,17 +670,17 @@ module.exports = function (options = {}) { followRedirect: true, }) - if (!caseInsensitiveGet(options.headers, 'user-agent') && (ua = headers['user-agent'])) { - options.headers['user-agent'] = ua + if (!caseInsensitiveGet(options.headers, 'user-agent') && userAgent) { + options.headers['user-agent'] = userAgent } // normalize case sensitivity // to be lowercase - a = options.headers.Accept + let accept = options.headers.Accept - if (a) { + if (accept) { delete options.headers.Accept - options.headers.accept = a + options.headers.accept = accept } // https://github.com/cypress-io/cypress/issues/338 @@ -785,9 +781,7 @@ module.exports = function (options = {}) { }) } - c = options.cookies - - if (c) { + if (options.cookies) { return self.setRequestCookieHeader(options, options.url, automationFn, caseInsensitiveGet(options.headers, 'cookie')) .then(send) } diff --git a/packages/server/lib/routes.ts b/packages/server/lib/routes.ts index 575307dd7a89..0dee424f5b77 100644 --- a/packages/server/lib/routes.ts +++ b/packages/server/lib/routes.ts @@ -175,6 +175,8 @@ export const createCommonRoutes = ({ router.get(clientRoute, (req: Request & { proxiedUrl?: string }, res) => { const nonProxied = req.proxiedUrl?.startsWith('/') ?? false + getCtx().actions.app.setBrowserUserAgent(req.headers['user-agent']) + // Chrome plans to make document.domain immutable in Chrome 109, with the default value // of the Origin-Agent-Cluster header becoming 'true'. We explicitly disable this header // so that we can continue to support tests that visit multiple subdomains in a single spec. diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index eccf7615b7d5..78cfbd7a9d6d 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -474,11 +474,11 @@ export class ServerBase { this.resourceTypeAndCredentialManager.clear() } - const io = this.socket.startListening(this.server, automation, config, options) + const ios = this.socket.startListening(this.server, automation, config, options) this._normalizeReqUrl(this.server) - return io + return ios } createHosts (hosts: {[key: string]: string} | null = {}) { @@ -530,9 +530,9 @@ export class ServerBase { }) } - _onRequest (headers, automationRequest, options) { + _onRequest (userAgent, automationRequest, options) { // @ts-ignore - return this.request.sendPromise(headers, automationRequest, options) + return this.request.sendPromise(userAgent, automationRequest, options) } _callRequestListeners (server, listeners, req, res) { @@ -712,10 +712,10 @@ export class ServerBase { }) } - _onResolveUrl (urlStr, headers, automationRequest, options: Record = { headers: {} }) { + _onResolveUrl (urlStr, userAgent, automationRequest, options: Record = { headers: {} }) { debug('resolving visit %o', { url: urlStr, - headers, + userAgent, options, }) @@ -967,7 +967,7 @@ export class ServerBase { return runPhase(() => { // @ts-ignore - return request.sendStream(headers, automationRequest, options) + return request.sendStream(userAgent, automationRequest, options) .then((createReqStream) => { const stream = createReqStream() diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index e05672513929..bff626cf5dff 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -6,6 +6,7 @@ import { getCtx } from '@packages/data-context' import { handleGraphQLSocketRequest } from '@packages/graphql/src/makeGraphQLServer' import { onNetStubbingEvent } from '@packages/net-stubbing' import * as socketIo from '@packages/socket' +import { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import firefoxUtil from './browsers/firefox-util' import * as errors from './errors' @@ -49,7 +50,8 @@ export class SocketBase { protected inRunMode: boolean protected supportsRunEvents: boolean protected ended: boolean - protected _io?: socketIo.SocketIOServer + protected _socketIo?: socketIo.SocketIOServer + protected _cdpIo?: CDPSocketServer localBus: EventEmitter constructor (config: Record) { @@ -61,16 +63,22 @@ export class SocketBase { protected ensureProp = ensureProp - get io () { - return this.ensureProp(this._io, 'startListening') + get socketIo () { + return this.ensureProp(this._socketIo, 'startListening') } - toReporter (event: string, data?: any) { - return this._io?.to('reporter').emit(event, data) + get cdpIo () { + return this._cdpIo + } + + getIos () { + return [this._cdpIo, this._socketIo] } toRunner (event: string, data?: any) { - return this._io?.to('runner').emit(event, data) + this.getIos().forEach((io) => { + io?.to('runner').emit(event, data) + }) } isSocketConnected (socket) { @@ -78,7 +86,9 @@ export class SocketBase { } toDriver (event, ...data) { - return this._io?.emit(event, ...data) + this.getIos().forEach((io) => { + io?.emit(event, ...data) + }) } onAutomation (socket, message, data, id) { @@ -97,7 +107,11 @@ export class SocketBase { throw new Error(`Could not process '${message}'. No automation clients connected.`) } - createIo (server: DestroyableHttpServer, path: string, cookie: string | boolean) { + createCDPIo (socketIoRoute: string) { + return new CDPSocketServer({ path: socketIoRoute }) + } + + createSocketIo (server: DestroyableHttpServer, path: string, cookie: string | boolean) { return new socketIo.SocketIOServer(server, { path, cookie: { @@ -142,11 +156,13 @@ export class SocketBase { const { socketIoRoute, socketIoCookie } = config - const io = this._io = this.createIo(server, socketIoRoute, socketIoCookie) + const socketIo = this._socketIo = this.createSocketIo(server, socketIoRoute, socketIoCookie) + const cdpIo = this._cdpIo = this.createCDPIo(socketIoRoute) automation.use({ onPush: (message, data) => { - return io.emit('automation:push:message', message, data) + socketIo.emit('automation:push:message', message, data) + cdpIo.emit('automation:push:message', message, data) }, }) @@ -166,421 +182,426 @@ export class SocketBase { const getFixture = (path, opts) => fixture.get(config.fixturesFolder, path, opts) - io.on('connection', (socket: Socket & { inReporterRoom?: boolean, inRunnerRoom?: boolean }) => { - if (socket.conn.transport.name === 'polling' && options.getCurrentBrowser()?.family !== 'webkit') { - debug('polling WebSocket request received with non-WebKit browser, disconnecting') - - // TODO(webkit): polling transport is only used for experimental WebKit, and it bypasses SocketAllowed, - // we d/c polling clients if we're not in WK. remove once WK ws proxying is fixed - return socket.disconnect(true) - } - - debug('socket connected') + this.getIos().forEach((io) => { + io?.on('connection', (socket: Socket & { inReporterRoom?: boolean, inRunnerRoom?: boolean }) => { + if (socket.conn && socket.conn.transport.name === 'polling' && options.getCurrentBrowser()?.family !== 'webkit') { + debug('polling WebSocket request received with non-WebKit browser, disconnecting') - socket.on('disconnecting', (reason) => { - debug(`socket-disconnecting ${reason}`) - }) + // TODO(webkit): polling transport is only used for experimental WebKit, and it bypasses SocketAllowed, + // we d/c polling clients if we're not in WK. remove once WK ws proxying is fixed + return socket.disconnect(true) + } - socket.on('disconnect', (reason) => { - debug(`socket-disconnect ${reason}`) - }) + debug('socket connected') - socket.on('error', (err) => { - debug(`socket-error ${err.message}`) - }) - - // cache the headers so we can access - // them at any time - const headers = socket.request?.headers ?? {} + socket.on('disconnecting', (reason) => { + debug(`socket-disconnecting ${reason}`) + }) - socket.on('automation:client:connected', () => { - const connectedBrowser = getCtx().coreData.activeBrowser + socket.on('disconnect', (reason) => { + debug(`socket-disconnect ${reason}`) + }) - if (automationClient === socket) { - return - } + socket.on('error', (err) => { + debug(`socket-error ${err.message}`) + }) - automationClient = socket + socket.on('automation:client:connected', () => { + const connectedBrowser = getCtx().coreData.activeBrowser - debug('automation:client connected') + if (automationClient === socket) { + return + } - // only send the necessary config - automationClient.emit('automation:config', {}) + automationClient = socket - // if our automation disconnects then we're - // in trouble and should probably bomb everything - automationClient.on('disconnect', () => { - const activeBrowser = getCtx().coreData.activeBrowser + debug('automation:client connected') - // if we've stopped or if we've switched to another browser then don't do anything - if (this.ended || (connectedBrowser?.path !== activeBrowser?.path)) { - return - } + // only send the necessary config + automationClient.emit('automation:config', {}) - // if we are in headless mode then log out an error and maybe exit with process.exit(1)? - return Bluebird.delay(2000) - .then(() => { - // bail if we've swapped to a new automationClient - if (automationClient !== socket) { - return - } + // if our automation disconnects then we're + // in trouble and should probably bomb everything + automationClient.on('disconnect', () => { + const { activeBrowser } = getCtx().coreData - // give ourselves about 2000ms to reconnect - // and if we're connected its all good - if (automationClient.connected) { + // if we've stopped or if we've switched to another browser then don't do anything + if (this.ended || (connectedBrowser?.path !== activeBrowser?.path)) { return } - // TODO: if all of our clients have also disconnected - // then don't warn anything - errors.warning('AUTOMATION_SERVER_DISCONNECTED') - - // TODO: no longer emit this, just close the browser and display message in reporter - io.emit('automation:disconnected') + // if we are in headless mode then log out an error and maybe exit with process.exit(1)? + return Bluebird.delay(2000) + .then(() => { + // bail if we've swapped to a new automationClient + if (automationClient !== socket) { + return + } + + // give ourselves about 2000ms to reconnect + // and if we're connected its all good + if (automationClient.connected) { + return + } + + // TODO: if all of our clients have also disconnected + // then don't warn anything + errors.warning('AUTOMATION_SERVER_DISCONNECTED') + + // TODO: no longer emit this, just close the browser and display message in reporter + io?.emit('automation:disconnected') + }) }) - }) - socket.on('automation:push:request', ( - message: string, - data: Record, - cb: (...args: unknown[]) => any, - ) => { - automation.push(message, data) + socket.on('automation:push:request', ( + message: string, + data: Record, + cb: (...args: unknown[]) => any, + ) => { + automation.push(message, data) + + // just immediately callback because there + // is not really an 'ack' here + if (cb) { + return cb() + } + }) - // just immediately callback because there - // is not really an 'ack' here - if (cb) { - return cb() - } + socket.on('automation:response', automation.response) }) - socket.on('automation:response', automation.response) - }) - - socket.on('automation:request', (message, data, cb) => { - debug('automation:request %s %o', message, data) + socket.on('automation:request', (message, data, cb) => { + debug('automation:request %s %o', message, data) - return automationRequest(message, data) - .then((resp) => { - return cb({ response: resp }) - }).catch((err) => { - return cb({ error: errors.cloneErr(err) }) + return automationRequest(message, data) + .then((resp) => { + return cb({ response: resp }) + }).catch((err) => { + return cb({ error: errors.cloneErr(err) }) + }) }) - }) - - this._sendResetBrowserTabsForNextTestMessage = async (shouldKeepTabOpen: boolean) => { - await automationRequest('reset:browser:tabs:for:next:test', { shouldKeepTabOpen }) - } - - this._sendResetBrowserStateMessage = async () => { - await automationRequest('reset:browser:state', {}) - } - this._sendFocusBrowserMessage = async () => { - await automationRequest('focus:browser:window', {}) - } - - this._isRunnerSocketConnected = () => { - return !!(runnerSocket && runnerSocket.connected) - } - - socket.on('reporter:connected', () => { - if (socket.inReporterRoom) { - return + this._sendResetBrowserTabsForNextTestMessage = async (shouldKeepTabOpen: boolean) => { + await automationRequest('reset:browser:tabs:for:next:test', { shouldKeepTabOpen }) } - socket.inReporterRoom = true - - return socket.join('reporter') - }) + this._sendResetBrowserStateMessage = async () => { + await automationRequest('reset:browser:state', {}) + } - // TODO: what to do about reporter disconnections? + this._sendFocusBrowserMessage = async () => { + await automationRequest('focus:browser:window', {}) + } - socket.on('runner:connected', () => { - if (socket.inRunnerRoom) { - return + this._isRunnerSocketConnected = () => { + return !!(runnerSocket && runnerSocket.connected) } - runnerSocket = socket + socket.on('reporter:connected', () => { + if (socket.inReporterRoom) { + return + } - socket.inRunnerRoom = true + socket.inReporterRoom = true - return socket.join('runner') - }) + return socket.join('reporter') + }) - // TODO: what to do about runner disconnections? - socket.on('spec:changed', (spec) => { - return options.onSpecChanged(spec) - }) + // TODO: what to do about reporter disconnections? - socket.on('app:connect', (socketId) => { - return options.onConnect(socketId, socket) - }) + socket.on('runner:connected', () => { + if (socket.inRunnerRoom) { + return + } - socket.on('set:runnables:and:maybe:record:tests', async (runnables, cb) => { - return options.onTestsReceivedAndMaybeRecord(runnables, cb) - }) + runnerSocket = socket - socket.on('mocha', (...args: unknown[]) => { - return options.onMocha.apply(options, args) - }) + socket.inRunnerRoom = true - socket.on('open:finder', (p, cb = function () {}) => { - return open.opn(p) - .then(() => { - return cb() + return socket.join('runner') }) - }) - socket.on('recorder:frame', (data) => { - return options.onCaptureVideoFrames(data) - }) + // TODO: what to do about runner disconnections? + socket.on('spec:changed', (spec) => { + return options.onSpecChanged(spec) + }) - socket.on('reload:browser', (url: string, browser: any) => { - return options.onReloadBrowser(url, browser) - }) + socket.on('app:connect', (socketId) => { + return options.onConnect(socketId, socket) + }) - socket.on('focus:tests', () => { - return options.onFocusTests() - }) + socket.on('set:runnables:and:maybe:record:tests', async (runnables, cb) => { + return options.onTestsReceivedAndMaybeRecord(runnables, cb) + }) - socket.on('is:automation:client:connected', ( - data: Record, - cb: (...args: unknown[]) => void, - ) => { - const isConnected = () => { - return automationRequest('is:automation:client:connected', data) - } + socket.on('mocha', (...args: unknown[]) => { + return options.onMocha.apply(options, args) + }) - const tryConnected = () => { - return Bluebird - .try(isConnected) - .catch(() => { - return retry(tryConnected) + socket.on('open:finder', (p, cb = function () {}) => { + return open.opn(p) + .then(() => { + return cb() }) - } + }) - // retry for up to data.timeout or 1 second - return Bluebird - .try(tryConnected) - .timeout(data.timeout != null ? data.timeout : 1000) - .then(() => { - return cb(true) - }).catch(Bluebird.TimeoutError, (_err) => { - return cb(false) + socket.on('recorder:frame', (data) => { + return options.onCaptureVideoFrames(data) }) - }) - const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: SerializableAutomationCookie, url: string, sameSiteContext: SameSiteContext }) => { - const domain = cors.getOrigin(url) + socket.on('reload:browser', (url: string, browser: any) => { + return options.onReloadBrowser(url, browser) + }) - cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, sameSiteContext) - } + socket.on('focus:tests', () => { + return options.onFocusTests() + }) + + socket.on('is:automation:client:connected', ( + data: Record, + cb: (...args: unknown[]) => void, + ) => { + const isConnected = () => { + return automationRequest('is:automation:client:connected', data) + } - socket.on('backend:request', (eventName: string, ...args) => { - // cb is always the last argument - const cb = args.pop() + const tryConnected = () => { + return Bluebird + .try(isConnected) + .catch(() => { + return retry(tryConnected) + }) + } - debug('backend:request %o', { eventName, args }) + // retry for up to data.timeout or 1 second + return Bluebird + .try(tryConnected) + .timeout(data.timeout != null ? data.timeout : 1000) + .then(() => { + return cb(true) + }).catch(Bluebird.TimeoutError, (_err) => { + return cb(false) + }) + }) - const backendRequest = () => { - switch (eventName) { - case 'preserve:run:state': - runState = args[0] + const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: SerializableAutomationCookie, url: string, sameSiteContext: SameSiteContext }) => { + const domain = cors.getOrigin(url) - return null - case 'resolve:url': { - const [url, resolveOpts] = args + cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, sameSiteContext) + } - return options.onResolveUrl(url, headers, automationRequest, resolveOpts) + socket.on('backend:request', (eventName: string, ...args) => { + const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent + + // cb is always the last argument + const cb = args.pop() + + debug('backend:request %o', { eventName, args }) + + const backendRequest = () => { + switch (eventName) { + case 'preserve:run:state': + runState = args[0] + + return null + case 'resolve:url': { + const [url, resolveOpts] = args + + return options.onResolveUrl(url, userAgent, automationRequest, resolveOpts) + } + case 'http:request': + return options.onRequest(userAgent, automationRequest, args[0]) + case 'reset:server:state': + return options.onResetServerState() + case 'log:memory:pressure': + return firefoxUtil.log() + case 'firefox:force:gc': + return firefoxUtil.collectGarbage() + case 'get:fixture': + return getFixture(args[0], args[1]) + case 'net': + return onNetStubbingEvent({ + eventName: args[0], + frame: args[1], + state: options.netStubbingState, + socket: this, + getFixture, + args, + }) + case 'save:session': + return session.saveSession(args[0]) + case 'clear:sessions': + return session.clearSessions(args[0]) + case 'get:session': + return session.getSession(args[0]) + case 'reset:cached:test:state': + runState = undefined + cookieJar.removeAllCookies() + session.clearSessions() + + return resetRenderedHTMLOrigins() + case 'get:rendered:html:origins': + return options.getRenderedHTMLOrigins() + case 'reset:rendered:html:origins': + return resetRenderedHTMLOrigins() + case 'cross:origin:cookies:received': + return this.localBus.emit('cross:origin:cookies:received') + case 'cross:origin:set:cookie': + return setCrossOriginCookie(args[0]) + case 'request:sent:with:credentials': + return this.localBus.emit('request:sent:with:credentials', args[0]) + case 'start:memory:profiling': + return memory.startProfiling(automation, args[0]) + case 'end:memory:profiling': + return memory.endProfiling() + case 'check:memory:pressure': + return memory.checkMemoryPressure({ ...args[0], automation }) + case 'protocol:test:before:run:async': + return this._protocolManager?.beforeTest(args[0]) + case 'protocol:test:before:after:run:async': + return this._protocolManager?.preAfterTest(args[0], args[1]) + case 'protocol:test:after:run:async': + return this._protocolManager?.afterTest(args[0]) + case 'protocol:command:log:added': + return this._protocolManager?.commandLogAdded(args[0]) + case 'protocol:command:log:changed': + return this._protocolManager?.commandLogChanged(args[0]) + case 'protocol:viewport:changed': + return this._protocolManager?.viewportChanged(args[0]) + case 'protocol:url:changed': + return this._protocolManager?.urlChanged(args[0]) + case 'protocol:page:loading': + return this._protocolManager?.pageLoading(args[0]) + case 'run:privileged': + return privilegedCommandsManager.runPrivilegedCommand(config, args[0]) + case 'telemetry': + return (telemetry.exporter() as OTLPTraceExporterCloud)?.send(args[0], () => {}, (err) => { + debug('error exporting telemetry data from browser %s', err) + }) + default: + throw new Error(`You requested a backend event we cannot handle: ${eventName}`) } - case 'http:request': - return options.onRequest(headers, automationRequest, args[0]) - case 'reset:server:state': - return options.onResetServerState() - case 'log:memory:pressure': - return firefoxUtil.log() - case 'firefox:force:gc': - return firefoxUtil.collectGarbage() - case 'get:fixture': - return getFixture(args[0], args[1]) - case 'net': - return onNetStubbingEvent({ - eventName: args[0], - frame: args[1], - state: options.netStubbingState, - socket: this, - getFixture, - args, - }) - case 'save:session': - return session.saveSession(args[0]) - case 'clear:sessions': - return session.clearSessions(args[0]) - case 'get:session': - return session.getSession(args[0]) - case 'reset:cached:test:state': - runState = undefined - cookieJar.removeAllCookies() - session.clearSessions() - - return resetRenderedHTMLOrigins() - case 'get:rendered:html:origins': - return options.getRenderedHTMLOrigins() - case 'reset:rendered:html:origins': - return resetRenderedHTMLOrigins() - case 'cross:origin:cookies:received': - return this.localBus.emit('cross:origin:cookies:received') - case 'cross:origin:set:cookie': - return setCrossOriginCookie(args[0]) - case 'request:sent:with:credentials': - return this.localBus.emit('request:sent:with:credentials', args[0]) - case 'start:memory:profiling': - return memory.startProfiling(automation, args[0]) - case 'end:memory:profiling': - return memory.endProfiling() - case 'check:memory:pressure': - return memory.checkMemoryPressure({ ...args[0], automation }) - case 'protocol:test:before:run:async': - return this._protocolManager?.beforeTest(args[0]) - case 'protocol:test:before:after:run:async': - return this._protocolManager?.preAfterTest(args[0], args[1]) - case 'protocol:test:after:run:async': - return this._protocolManager?.afterTest(args[0]) - case 'protocol:command:log:added': - return this._protocolManager?.commandLogAdded(args[0]) - case 'protocol:command:log:changed': - return this._protocolManager?.commandLogChanged(args[0]) - case 'protocol:viewport:changed': - return this._protocolManager?.viewportChanged(args[0]) - case 'protocol:url:changed': - return this._protocolManager?.urlChanged(args[0]) - case 'protocol:page:loading': - return this._protocolManager?.pageLoading(args[0]) - case 'run:privileged': - return privilegedCommandsManager.runPrivilegedCommand(config, args[0]) - case 'telemetry': - return (telemetry.exporter() as OTLPTraceExporterCloud)?.send(args[0], () => {}, (err) => { - debug('error exporting telemetry data from browser %s', err) - }) - default: - throw new Error(`You requested a backend event we cannot handle: ${eventName}`) } - } - return Bluebird.try(backendRequest) - .then((resp) => { - return cb({ response: resp }) - }).catch((err) => { - return cb({ error: errors.cloneErr(err) }) + return Bluebird.try(backendRequest) + .then((resp) => { + return cb({ response: resp }) + }).catch((err) => { + return cb({ error: errors.cloneErr(err) }) + }) }) - }) - - socket.on('get:cached:test:state', (cb: (runState: RunState | null, testState: CachedTestState) => void) => { - const s = runState - - const cachedTestState: CachedTestState = { - activeSessions: session.getActiveSessions(), - } - if (s) { - runState = undefined + socket.on('get:cached:test:state', (cb: (runState: RunState | null, testState: CachedTestState) => void) => { + const s = runState - // if we have cached test state, then we need to reset - // the test state on the protocol manager - if (s.currentId) { - this._protocolManager?.resetTest(s.currentId) + const cachedTestState: CachedTestState = { + activeSessions: session.getActiveSessions(), } - } - - return cb(s || {}, cachedTestState) - }) - socket.on('save:app:state', (state, cb) => { - options.onSavedStateChanged(state) + if (s) { + runState = undefined - // we only use the 'ack' here in tests - if (cb) { - return cb() - } - }) + // if we have cached test state, then we need to reset + // the test state on the protocol manager + if (s.currentId) { + this._protocolManager?.resetTest(s.currentId) + } + } - socket.on('external:open', (url: string) => { - debug('received external:open %o', { url }) - // using this instead of require('electron').shell.openExternal - // because CT runner does not spawn an electron shell - // if we eventually decide to exclusively launch CT from - // the desktop-gui electron shell, we should update this to use - // electron.shell.openExternal. + return cb(s || {}, cachedTestState) + }) - // cross platform way to open a new tab in default browser, or a new browser window - // if one does not already exist for the user's default browser. - const start = (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open') + socket.on('save:app:state', (state, cb) => { + options.onSavedStateChanged(state) - return require('child_process').exec(`${start} ${url}`) - }) + // we only use the 'ack' here in tests + if (cb) { + return cb() + } + }) - socket.on('get:user:editor', (cb) => { - getUserEditor(false) - .then(cb) - .catch(() => {}) - }) + socket.on('external:open', (url: string) => { + debug('received external:open %o', { url }) + // using this instead of require('electron').shell.openExternal + // because CT runner does not spawn an electron shell + // if we eventually decide to exclusively launch CT from + // the desktop-gui electron shell, we should update this to use + // electron.shell.openExternal. - socket.on('set:user:editor', (editor) => { - setUserEditor(editor).catch(() => {}) - }) + // cross platform way to open a new tab in default browser, or a new browser window + // if one does not already exist for the user's default browser. + const start = (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open') - socket.on('open:file', async (fileDetails: OpenFileDetails) => { - // todo(lachlan): post 10.0 we should not pass the - // editor (in the `fileDetails.where` key) from the - // front-end, but rather rely on the server context - // to grab the preferred editor, like I'm doing here, - // so we do not need to - // maintain two sources of truth for the preferred editor - // adding this conditional to maintain backwards compat with - // existing runner and reporter API. - fileDetails.where = { - binary: getCtx().coreData.localSettings.preferences.preferredEditorBinary || 'computer', - } + return require('child_process').exec(`${start} ${url}`) + }) - debug('opening file %o', fileDetails) + socket.on('get:user:editor', (cb) => { + getUserEditor(false) + .then(cb) + .catch(() => {}) + }) - openFile(fileDetails) - }) + socket.on('set:user:editor', (editor) => { + setUserEditor(editor).catch(() => {}) + }) - if (this.supportsRunEvents) { - socket.on('plugins:before:spec', (spec, cb) => { - const beforeSpecSpan = telemetry.startSpan({ name: 'lifecycle:before:spec' }) + socket.on('open:file', async (fileDetails: OpenFileDetails) => { + // todo(lachlan): post 10.0 we should not pass the + // editor (in the `fileDetails.where` key) from the + // front-end, but rather rely on the server context + // to grab the preferred editor, like I'm doing here, + // so we do not need to + // maintain two sources of truth for the preferred editor + // adding this conditional to maintain backwards compat with + // existing runner and reporter API. + fileDetails.where = { + binary: getCtx().coreData.localSettings.preferences.preferredEditorBinary || 'computer', + } - beforeSpecSpan?.setAttributes({ spec }) + debug('opening file %o', fileDetails) - runEvents.execute('before:spec', spec) - .then(cb) - .catch((error) => { - if (this.inRunMode) { - socket.disconnect() - throw error - } + openFile(fileDetails) + }) - // surfacing the error to the app in open mode - cb({ error }) + if (this.supportsRunEvents) { + socket.on('plugins:before:spec', (spec, cb) => { + const beforeSpecSpan = telemetry.startSpan({ name: 'lifecycle:before:spec' }) + + beforeSpecSpan?.setAttributes({ spec }) + + runEvents.execute('before:spec', spec) + .then(cb) + .catch((error) => { + if (this.inRunMode) { + socket.disconnect() + throw error + } + + // surfacing the error to the app in open mode + cb({ error }) + }) + .finally(() => { + beforeSpecSpan?.end() + }) }) - .finally(() => { - beforeSpecSpan?.end() - }) - }) - } + } - callbacks.onSocketConnection(socket) + callbacks.onSocketConnection(socket) - return + return + }) }) - io.of('/data-context').on('connection', (socket: Socket) => { - socket.on('graphql:request', handleGraphQLSocketRequest) + this.getIos().forEach((io) => { + io?.of('/data-context').on('connection', (socket: Socket) => { + socket.on('graphql:request', handleGraphQLSocketRequest) + }) }) - return io + return { + cdpIo: this._cdpIo, + socketIo: this._socketIo, + } } end () { @@ -588,7 +609,9 @@ export class SocketBase { // TODO: we need an 'ack' from this end // event from the other side - return this._io?.emit('tests:finished') + this.getIos().forEach((io) => { + io?.emit('tests:finished') + }) } async resetBrowserTabsForNextTest (shouldKeepTabOpen: boolean) { @@ -614,7 +637,7 @@ export class SocketBase { } close () { - return this._io?.close() + this.getIos().forEach((io) => io?.close()) } changeToUrl (url: string) { diff --git a/packages/server/lib/socket-e2e.ts b/packages/server/lib/socket-e2e.ts index af1dec4ea351..1cef7daba81a 100644 --- a/packages/server/lib/socket-e2e.ts +++ b/packages/server/lib/socket-e2e.ts @@ -46,7 +46,8 @@ export class SocketE2E extends SocketBase { return fs.statAsync(filePath) .then(() => { - return this._io?.emit('watched:file:changed') + this._cdpIo?.emit('watched:file:changed') + this._socketIo?.emit('watched:file:changed') }).catch(() => { return debug('could not find test file that changed %o', filePath) }) diff --git a/packages/server/package.json b/packages/server/package.json index ea901f1d4048..4dfd91f5564c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -16,6 +16,7 @@ "repl": "node repl.js", "start": "node ../../scripts/cypress open --dev --global", "test": "node ./test/scripts/run.js", + "test-watch": "./test/scripts/watch test", "test-integration": "node ./test/scripts/run.js --glob-in-dir=test/integration", "test-performance": "node ./test/scripts/run.js --glob-in-dir=test/performance", "test-unit": "node ./test/scripts/run.js --glob-in-dir=test/unit", @@ -171,7 +172,7 @@ "chai-uuid": "1.0.6", "chrome-har-capturer": "0.13.4", "cross-env": "6.0.3", - "devtools-protocol": "0.0.1124027", + "devtools-protocol": "0.0.927104", "eol": "0.9.1", "esbuild": "^0.15.3", "eventsource": "2.0.2", diff --git a/packages/server/test/integration/cdp_spec.ts b/packages/server/test/integration/cdp_spec.ts new file mode 100644 index 000000000000..fbf2f614f3c7 --- /dev/null +++ b/packages/server/test/integration/cdp_spec.ts @@ -0,0 +1,334 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + +import Debug from 'debug' +import _ from 'lodash' +import { Server as WebSocketServer } from 'ws' +import { CdpCommand, CdpEvent } from '../../lib/browsers/cdp_automation' +import * as CriClient from '../../lib/browsers/cri-client' +import { expect, nock } from '../spec_helper' + +import type { SinonStub } from 'sinon' +// import Bluebird from 'bluebird' + +const debug = Debug('cypress:server:tests') + +const wsServerPort = 20000 + +type CDPCommands = { + command: CdpCommand + params?: object +} + +type CDPSubscriptions = { + eventName: CdpEvent + cb: () => void +} + +type OnWSConnection = (wsClient: WebSocket) => void + +describe('CDP Clients', () => { + require('mocha-banner').register() + + let wsSrv: WebSocketServer + let criClient: CriClient.CriClient + let messages: object[] + let onMessage: SinonStub + + const startWsServer = async (onConnection?: OnWSConnection): Promise => { + return new Promise((resolve, reject) => { + const srv = new WebSocketServer({ + port: wsServerPort, + }) + + srv.on('connection', (ws) => { + if (onConnection) { + onConnection(ws) + } + + // eslint-disable-next-line no-console + ws.on('error', console.error) + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + + messages.push(msg) + onMessage(msg) + + // ACK back if we have a msg.id + if (msg.id) { + ws.send(JSON.stringify({ + id: msg.id, + result: {}, + })) + } + }) + }) + + srv.on('error', reject) + srv.on('listening', () => { + resolve(srv) + }) + }) + } + + const closeWsServer = () => { + debug('closing websocket server') + + return new Promise((resolve, reject) => { + wsSrv.close((err) => { + if (err) { + return reject(err) + } + + debug('closed websocket server') + resolve(undefined) + }) + }) + } + + const clientDisconnected = () => { + return new Promise((resolve, reject) => { + criClient.ws.once('close', resolve) + }) + } + + beforeEach(async () => { + messages = [] + + onMessage = sinon.stub() + + nock.enableNetConnect() + + wsSrv = await startWsServer() + }) + + afterEach(async () => { + await criClient.close().catch(() => { }) + await closeWsServer() + }) + + context('reconnect after disconnect', () => { + it('retries to connect', async () => { + const stub = sinon.stub() + + return new Promise(async (resolve, reject) => { + const onAsynchronousError = reject + const onReconnect = resolve + + criClient = await CriClient.create( + `ws://127.0.0.1:${wsServerPort}`, + onAsynchronousError, + undefined, + undefined, + onReconnect, + ) + + criClient.onReconnectAttempt = stub + + await Promise.all([ + clientDisconnected(), + closeWsServer(), + ]) + + wsSrv = await startWsServer() + }) + .then((client) => { + expect(client).to.eq(criClient) + + expect(stub).to.be.calledWith(1) + expect(stub.callCount).to.be.gte(1) + }) + }) + + it('retries up to 20 times and then throws an error', () => { + const stub = sinon.stub() + + return new Promise(async (resolve, reject) => { + const onAsynchronousError = resolve + const onReconnect = reject + + criClient = await CriClient.create( + `ws://127.0.0.1:${wsServerPort}`, + onAsynchronousError, + undefined, + undefined, + onReconnect, + ) + + criClient.onReconnectAttempt = stub + + await Promise.all([ + clientDisconnected(), + closeWsServer(), + ]) + }) + .then((err) => { + expect(err).to.have.property('type', 'CDP_COULD_NOT_RECONNECT') + expect(err).to.have.property('isFatalApiErr', true) + + expect(stub).to.be.calledWith(20) + expect(stub.callCount).to.be.eq(20) + }) + }) + + it('restores sending enqueued commands, subscriptions, and enable commands on reconnect', () => { + const enableCommands: CDPCommands[] = [ + { command: 'Page.enable', params: {} }, + { command: 'Network.enable', params: {} }, + { command: 'Runtime.addBinding', params: { name: 'foo' } }, + { command: 'Target.setDiscoverTargets', params: { discover: true } }, + ] + + const enqueuedCommands: CDPCommands[] = [ + { command: 'Page.navigate', params: { url: 'about:blank' } }, + { command: 'Performance.getMetrics', params: {} }, + ] + + const cb = sinon.stub() + + const subscriptions: CDPSubscriptions[] = [ + { eventName: 'Network.requestWillBeSent', cb }, + { eventName: 'Network.responseReceived', cb }, + ] + + let wsClient + + const stub = sinon.stub().onThirdCall().callsFake(async () => { + wsSrv = await startWsServer((ws) => { + wsClient = ws + }) + }) + + const onReconnect = sinon.stub() + + return new Promise(async (resolve, reject) => { + const onAsynchronousError = reject + + criClient = await CriClient.create( + `ws://127.0.0.1:${wsServerPort}`, + onAsynchronousError, + undefined, + undefined, + onReconnect, + ) + + criClient.onReconnectAttempt = stub + + const send = (commands: CDPCommands[]) => { + commands.forEach(({ command, params }) => { + return criClient.send(command, params) + }) + } + + const on = (subscriptions: CDPSubscriptions[]) => { + subscriptions.forEach(({ eventName, cb }) => { + return criClient.on(eventName, cb) + }) + } + + // send these in before we disconnect + send(enableCommands) + + await Promise.all([ + clientDisconnected(), + closeWsServer(), + ]) + + // expect 6 message calls + onMessage = sinon.stub().onCall(5).callsFake(resolve) + + // now enqueue these commands + send(enqueuedCommands) + on(subscriptions) + + const { queue } = criClient + + // assert they're in the queue + expect(queue.enqueuedCommands).to.containSubset(enqueuedCommands) + expect(queue.enableCommands).to.containSubset(enableCommands) + expect(queue.subscriptions).to.containSubset(subscriptions.map(({ eventName, cb }) => { + return { + eventName, + cb: _.isFunction, + } + })) + }) + .then(() => { + const { queue } = criClient + + expect(queue.enqueuedCommands).to.be.empty + expect(queue.enableCommands).not.to.be.empty + expect(queue.subscriptions).not.to.be.empty + + const messageCalls = _ + .chain(onMessage.args) + .flatten() + .map(({ method, params }) => { + return { + command: method, + params: params ?? {}, + } + }) + .value() + + expect(onMessage).to.have.callCount(6) + expect(messageCalls).to.deep.eq( + _.concat( + enableCommands, + enqueuedCommands, + ), + ) + + return new Promise((resolve) => { + cb.onSecondCall().callsFake(resolve) + + wsClient.send(JSON.stringify({ + method: 'Network.requestWillBeSent', + params: { foo: 'bar' }, + })) + + wsClient.send(JSON.stringify({ + method: 'Network.responseReceived', + params: { baz: 'quux' }, + })) + }) + }) + .then(() => { + expect(cb.firstCall).to.be.calledWith({ foo: 'bar' }) + expect(cb.secondCall).to.be.calledWith({ baz: 'quux' }) + }) + }) + + it('stops reconnecting after close is called', () => { + return new Promise(async (resolve, reject) => { + const onAsynchronousError = reject + const onReconnect = reject + + criClient = await CriClient.create( + `ws://127.0.0.1:${wsServerPort}`, + onAsynchronousError, + undefined, + undefined, + onReconnect, + ) + + const stub = sinon.stub().onThirdCall().callsFake(async () => { + criClient.close() + .finally(() => { + resolve(stub) + }) + }) + + criClient.onReconnectAttempt = stub + + await Promise.all([ + clientDisconnected(), + closeWsServer(), + ]) + }) + .then((stub) => { + expect(criClient.closed).to.be.true + expect((stub as SinonStub).callCount).to.be.eq(3) + }) + }) + }) +}) diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index ae94f200226e..1a0f9dada530 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -999,6 +999,7 @@ describe('lib/cypress', () => { // use the Chrome remote interface client const criClient = { on: sinon.stub(), + off: sinon.stub(), send: sinon.stub(), } const browserCriClient = { @@ -1065,6 +1066,7 @@ describe('lib/cypress', () => { // use the Chrome remote interface client const criClient = { on: sinon.stub(), + off: sinon.stub(), send: sinon.stub(), } const browserCriClient = { diff --git a/packages/server/test/integration/server_spec.js b/packages/server/test/integration/server_spec.js index 073820591868..8ffa7095448b 100644 --- a/packages/server/test/integration/server_spec.js +++ b/packages/server/test/integration/server_spec.js @@ -430,11 +430,9 @@ describe('Server', () => { 'Cache-Control': 'public, max-age=3600', }) - const headers = {} + const userAgent = 'foobarbaz' - headers['user-agent'] = 'foobarbaz' - - return this.server._onResolveUrl('http://getbootstrap.com/', headers, this.automationRequest) + return this.server._onResolveUrl('http://getbootstrap.com/', userAgent, this.automationRequest) .then((obj = {}) => { return expectToEqDetails(obj, { isOkStatusCode: true, @@ -833,11 +831,9 @@ describe('Server', () => { 'Cache-Control': 'public, max-age=3600', }) - const headers = {} - - headers['user-agent'] = 'foobarbaz' + const userAgent = 'foobarbaz' - return this.server._onResolveUrl('http://cypress.io/foo', headers, this.automationRequest, { failOnStatusCode: false }) + return this.server._onResolveUrl('http://cypress.io/foo', userAgent, this.automationRequest, { failOnStatusCode: false }) .then((obj = {}) => { return expectToEqDetails(obj, { isOkStatusCode: true, @@ -890,11 +886,9 @@ describe('Server', () => { 'Content-Type': 'text/html', }) - const headers = {} - - headers['user-agent'] = 'foobarbaz' + const userAgent = 'foobarbaz' - return this.server._onResolveUrl('http://google.com/index', headers, this.automationRequest, { auth }) + return this.server._onResolveUrl('http://google.com/index', userAgent, this.automationRequest, { auth }) .then((obj = {}) => { return expectToEqDetails(obj, { isOkStatusCode: true, diff --git a/packages/server/test/scripts/watch b/packages/server/test/scripts/watch new file mode 100755 index 000000000000..f759dbc758f6 --- /dev/null +++ b/packages/server/test/scripts/watch @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +CMD="$1" +ARGS="$2" + +npm run --silent $CMD $ARGS & \ +chokidar 'test/**/*' 'lib/**/*' \ +-c "yarn --silent $CMD $ARGS" \ +--polling \ +--poll-interval=250 diff --git a/packages/server/test/unit/browsers/browsers_spec.js b/packages/server/test/unit/browsers/browsers_spec.js index e5929a4e5ebd..2175d4e2e6e8 100644 --- a/packages/server/test/unit/browsers/browsers_spec.js +++ b/packages/server/test/unit/browsers/browsers_spec.js @@ -385,14 +385,14 @@ describe('lib/browsers/index', () => { browsers._setInstance(instance) sinon.stub(electron, 'open').resolves(instance) - sinon.spy(ctx.browser, 'setBrowserStatus') + sinon.spy(ctx.actions.app, 'setBrowserStatus') // Stub to speed up test, we don't care about the delay sinon.stub(Promise, 'delay').resolves() return browsers.open({ name: 'electron', family: 'chromium' }, { url }, null, ctx).then(browsers.close).then(() => { ['opening', 'open', 'closed'].forEach((status, i) => { - expect(ctx.browser.setBrowserStatus.getCall(i).args[0]).eq(status) + expect(ctx.actions.app.setBrowserStatus.getCall(i).args[0]).eq(status) }) }) }) diff --git a/packages/server/test/unit/browsers/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index ab6026c2e651..7843ae6ee35d 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -16,6 +16,7 @@ context('lib/browsers/cdp_automation', () => { } const localCommand = sinon.stub() const localOnFn = sinon.stub() + const localOffFn = sinon.stub() const localSendCloseTargetCommand = sinon.stub() const localAutomation = { onBrowserPreRequest: sinon.stub(), @@ -27,7 +28,7 @@ context('lib/browsers/cdp_automation', () => { const localCommmandStub = localCommand.withArgs('Network.enable', enabledObject).resolves() - await CdpAutomation.create(localCommand, localOnFn, localSendCloseTargetCommand, localAutomation as any, localManager) + await CdpAutomation.create(localCommand, localOnFn, localOffFn, localSendCloseTargetCommand, localAutomation as any, localManager) expect(localCommmandStub).to.have.been.calledWith('Network.enable', enabledObject) }) @@ -40,6 +41,7 @@ context('lib/browsers/cdp_automation', () => { } const localCommand = sinon.stub() const localOnFn = sinon.stub() + const localOffFn = sinon.stub() const localSendCloseTargetCommand = sinon.stub() const localAutomation = { onBrowserPreRequest: sinon.stub(), @@ -51,8 +53,8 @@ context('lib/browsers/cdp_automation', () => { const localCommmandStub = localCommand.withArgs('Network.enable', disabledObject).resolves() - await CdpAutomation.create(localCommand, localOnFn, localSendCloseTargetCommand, localAutomation as any, localManager) - await CdpAutomation.create(localCommand, localOnFn, localSendCloseTargetCommand, localAutomation as any) + await CdpAutomation.create(localCommand, localOnFn, localOffFn, localSendCloseTargetCommand, localAutomation as any, localManager) + await CdpAutomation.create(localCommand, localOnFn, localOffFn, localSendCloseTargetCommand, localAutomation as any) expect(localCommmandStub).to.have.been.calledTwice expect(localCommmandStub).to.have.been.calledWithExactly('Network.enable', disabledObject) @@ -62,6 +64,8 @@ context('lib/browsers/cdp_automation', () => { beforeEach(async function () { this.sendDebuggerCommand = sinon.stub() this.onFn = sinon.stub() + this.offFn = sinon.stub() + this.sendCloseTargetCommand = sinon.stub() this.automation = { onBrowserPreRequest: sinon.stub(), @@ -69,7 +73,7 @@ context('lib/browsers/cdp_automation', () => { onRequestServedFromCache: sinon.stub(), } - cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.sendCloseTargetCommand, this.automation) + cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.offFn, this.sendCloseTargetCommand, this.automation) this.onRequest = cdpAutomation.onRequest }) diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index e99b0be2a88b..3114ea33fc93 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -501,6 +501,10 @@ describe('lib/browsers/chrome', () => { targetId: '1234', } + const cdpSocketServer = { + attachCDPClient: sinon.stub(), + } + const browserCriClient = { currentlyAttachedTarget: pageCriClient, host: 'http://localhost', @@ -511,10 +515,6 @@ describe('lib/browsers/chrome', () => { use: sinon.stub().returns(), } - const launchedBrowser = { - kill: sinon.stub().returns(), - } - let onInitializeNewBrowserTabCalled = false const options = { ...openOpts, @@ -533,7 +533,7 @@ describe('lib/browsers/chrome', () => { sinon.stub(chrome, '_navigateUsingCRI').withArgs(pageCriClient, options.url, 354).resolves() sinon.stub(chrome, '_handleDownloads').withArgs(pageCriClient, options.downloadFolder, automation).resolves() - await chrome.connectToNewSpec({ majorVersion: 354 }, options, automation, launchedBrowser) + await chrome.connectToNewSpec({ majorVersion: 354 }, options, automation, cdpSocketServer) expect(automation.use).to.be.called expect(chrome._getBrowserCriClient).to.be.called @@ -541,6 +541,7 @@ describe('lib/browsers/chrome', () => { expect(chrome._navigateUsingCRI).to.be.called expect(chrome._handleDownloads).to.be.called expect(onInitializeNewBrowserTabCalled).to.be.true + expect(cdpSocketServer.attachCDPClient).to.be.calledWith(pageCriClient) expect(protocolManager.connectToBrowser).to.be.calledWith(pageCriClient) }) }) diff --git a/packages/server/test/unit/browsers/cri-client_spec.ts b/packages/server/test/unit/browsers/cri-client_spec.ts index 5c3684e12d71..99176bf7ddf5 100644 --- a/packages/server/test/unit/browsers/cri-client_spec.ts +++ b/packages/server/test/unit/browsers/cri-client_spec.ts @@ -12,11 +12,13 @@ describe('lib/browsers/cri-client', function () { create: typeof create } let send: sinon.SinonStub + let on: sinon.SinonStub let criImport: sinon.SinonStub & { New: sinon.SinonStub } let criStub: { send: typeof send + on: typeof on close: sinon.SinonStub _notifier: EventEmitter } @@ -26,9 +28,11 @@ describe('lib/browsers/cri-client', function () { beforeEach(function () { send = sinon.stub() onError = sinon.stub() + on = sinon.stub() criStub = { + on, send, - close: sinon.stub(), + close: sinon.stub().resolves(), _notifier: new EventEmitter(), } @@ -62,8 +66,8 @@ describe('lib/browsers/cri-client', function () { send.resolves() const client = await getClient() - client.send('Browser.getVersion', { baz: 'quux' }) - expect(send).to.be.calledWith('Browser.getVersion', { baz: 'quux' }) + client.send('DOM.getDocument', { depth: -1 }) + expect(send).to.be.calledWith('DOM.getDocument', { depth: -1 }) }) it('rejects if cri.send rejects', async function () { @@ -72,7 +76,7 @@ describe('lib/browsers/cri-client', function () { send.rejects(err) const client = await getClient() - await expect(client.send('Browser.getVersion', { baz: 'quux' })) + await expect(client.send('DOM.getDocument', { depth: -1 })) .to.be.rejectedWith(err) }) @@ -90,7 +94,7 @@ describe('lib/browsers/cri-client', function () { const client = await getClient() - await client.send('Browser.getVersion', { baz: 'quux' }) + await client.send('DOM.getDocument', { depth: -1 }) expect(send).to.be.calledTwice }) @@ -107,7 +111,7 @@ describe('lib/browsers/cri-client', function () { await client.close() - expect(client.send('Browser.getVersion', { baz: 'quux' })).to.be.rejectedWith('Browser.getVersion will not run as browser CRI connection was reset') + expect(client.send('DOM.getDocument', { depth: -1 })).to.be.rejectedWith('DOM.getDocument will not run as browser CRI connection was reset') }) }) }) @@ -132,7 +136,7 @@ describe('lib/browsers/cri-client', function () { criStub.send.reset() // @ts-ignore - await criStub._notifier.on.withArgs('disconnect').args[0][1]() + await criStub.on.withArgs('disconnect').args[0][1]() expect(criStub.send).to.be.calledTwice expect(criStub.send).to.be.calledWith('Page.enable') @@ -145,7 +149,7 @@ describe('lib/browsers/cri-client', function () { await getClient() // @ts-ignore - await criStub._notifier.on.withArgs('disconnect').args[0][1]() + await criStub.on.withArgs('disconnect').args[0][1]() expect(onError).to.be.called diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index bf27ca700310..10a09998feed 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -242,7 +242,7 @@ describe('lib/browsers/electron', () => { }) it('sets menu.set whether or not its in headless mode', function () { - return electron._launch(this.win, this.url, this.automation, { show: true, onError: () => {} }) + return electron._launch(this.win, this.url, this.automation, { show: true, onError: () => {} }, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(menu.set).to.be.calledWith({ withInternalDevTools: true }) }).then(() => { @@ -255,36 +255,36 @@ describe('lib/browsers/electron', () => { }) it('sets user agent if options.userAgent', function () { - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(electron._setUserAgent).not.to.be.called }).then(() => { - return electron._launch(this.win, this.url, this.automation, { userAgent: 'foo', onError: () => {} }) + return electron._launch(this.win, this.url, this.automation, { userAgent: 'foo', onError: () => {} }, undefined, undefined, { attachCDPClient: sinon.stub() }) }).then(() => { expect(electron._setUserAgent).to.be.calledWith(this.win.webContents, 'foo') }) }) it('sets proxy if options.proxyServer', function () { - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(electron._setProxy).not.to.be.called }).then(() => { - return electron._launch(this.win, this.url, this.automation, { proxyServer: 'foo', onError: () => {} }) + return electron._launch(this.win, this.url, this.automation, { proxyServer: 'foo', onError: () => {} }, undefined, undefined, { attachCDPClient: sinon.stub() }) }).then(() => { expect(electron._setProxy).to.be.calledWith(this.win.webContents, 'foo') }) }) it('calls win.loadURL with url', function () { - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.win.loadURL).to.be.calledWith(this.url) }) }) it('resolves with win', function () { - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then((win) => { expect(win).to.eq(this.win) }) @@ -303,7 +303,7 @@ describe('lib/browsers/electron', () => { this.options.downloadsFolder = 'downloads' sinon.stub(this.automation, 'push') - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.automation.push).to.be.calledWith('create:download', { id: '1', @@ -327,7 +327,7 @@ describe('lib/browsers/electron', () => { this.options.downloadsFolder = 'downloads' sinon.stub(this.automation, 'push') - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.automation.push).to.be.calledWith('complete:download', { id: '1', @@ -338,7 +338,7 @@ describe('lib/browsers/electron', () => { it('sets download behavior', function () { this.options.downloadsFolder = 'downloads' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.pageCriClient.send).to.be.calledWith('Page.setDownloadBehavior', { behavior: 'allow', @@ -350,7 +350,7 @@ describe('lib/browsers/electron', () => { it('registers onRequest automation middleware and calls show when requesting to be focused', function () { sinon.spy(this.automation, 'use') - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.automation.use).to.be.called expect(this.automation.use.lastCall.args[0].onRequest).to.be.a('function') @@ -364,12 +364,12 @@ describe('lib/browsers/electron', () => { it('registers onRequest automation middleware and calls destroy when requesting to close the browser tabs', function () { sinon.spy(this.automation, 'use') - return electron._launch(this.win, this.url, this.automation, this.options) - .then(() => { + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) + .then(async () => { expect(this.automation.use).to.be.called expect(this.automation.use.lastCall.args[0].onRequest).to.be.a('function') - this.automation.use.lastCall.args[0].onRequest('reset:browser:tabs:for:next:test', { shouldKeepTabOpen: true }) + await this.automation.use.lastCall.args[0].onRequest('reset:browser:tabs:for:next:test', { shouldKeepTabOpen: true }) expect(this.win.destroy).to.be.called }) @@ -400,7 +400,7 @@ describe('lib/browsers/electron', () => { }) it('sends Fetch.enable only for Document ResourceType', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) expect(this.pageCriClient.send).to.have.been.calledWith('Fetch.enable', { patterns: [{ @@ -410,7 +410,7 @@ describe('lib/browsers/electron', () => { }) it('does not add header when not a document', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) this.pageCriClient.on.withArgs('Fetch.requestPaused').yield({ requestId: '1234', @@ -421,7 +421,7 @@ describe('lib/browsers/electron', () => { }) it('does not add header when it is a spec frame request', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) this.pageCriClient.on.withArgs('Page.frameAttached').yield() @@ -440,7 +440,7 @@ describe('lib/browsers/electron', () => { }) it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) this.pageCriClient.on.withArgs('Page.frameAttached').yield() @@ -472,7 +472,7 @@ describe('lib/browsers/electron', () => { }) it('gets frame tree on Page.frameAttached', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) this.pageCriClient.on.withArgs('Page.frameAttached').yield() @@ -480,7 +480,7 @@ describe('lib/browsers/electron', () => { }) it('gets frame tree on Page.frameDetached', async function () { - await electron._launch(this.win, this.url, this.automation, this.options) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) this.pageCriClient.on.withArgs('Page.frameDetached').yield() @@ -488,7 +488,7 @@ describe('lib/browsers/electron', () => { }) it('connects the protocol manager to the browser', async function () { - await electron._launch(this.win, this.url, this.automation, this.options, undefined, this.protocolManager) + await electron._launch(this.win, this.url, this.automation, this.options, undefined, this.protocolManager, { attachCDPClient: sinon.stub() }) expect(this.protocolManager.connectToBrowser).to.be.calledWith(this.pageCriClient) }) @@ -507,7 +507,7 @@ describe('lib/browsers/electron', () => { it('does not attempt to replace the user agent', function () { this.options.experimentalModifyObstructiveThirdPartyCode = false - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.win.webContents.session.setUserAgent).not.to.be.called expect(this.pageCriClient.send).not.to.be.calledWith('Network.setUserAgentOverride', { @@ -527,7 +527,7 @@ describe('lib/browsers/electron', () => { this.options.experimentalModifyObstructiveThirdPartyCode = false this.options.userAgent = 'foobar' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.win.webContents.session.setUserAgent).to.be.calledWith('foobar') expect(this.win.webContents.session.setUserAgent).not.to.be.calledWith('barbaz') @@ -540,7 +540,7 @@ describe('lib/browsers/electron', () => { it('versioned cypress', function () { userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cypress/10.0.3 Chrome/100.0.4896.75 Electron/18.0.4 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36' @@ -554,7 +554,7 @@ describe('lib/browsers/electron', () => { it('development cypress', function () { userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cypress/0.0.0-development Chrome/100.0.4896.75 Electron/18.0.4 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36' @@ -568,7 +568,7 @@ describe('lib/browsers/electron', () => { it('older Windows user agent', function () { userAgent = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) electron/1.0.0 Chrome/53.0.2785.113 Electron/1.4.3 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.113 Safari/537.36' @@ -582,7 +582,7 @@ describe('lib/browsers/electron', () => { it('newer Windows user agent', function () { userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Teams/1.5.00.4689 Chrome/85.0.4183.121 Electron/10.4.7 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Teams/1.5.00.4689 Chrome/85.0.4183.121 Safari/537.36' @@ -596,7 +596,7 @@ describe('lib/browsers/electron', () => { it('Linux user agent', function () { userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Typora/0.9.93 Chrome/83.0.4103.119 Electron/9.0.5 Safari/E7FBAF' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Typora/0.9.93 Chrome/83.0.4103.119 Safari/E7FBAF' @@ -611,7 +611,7 @@ describe('lib/browsers/electron', () => { // this user agent containing Cypress was actually a common UA found on a website for Electron purposes... userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cypress/8.3.0 Chrome/91.0.4472.124 Electron/13.1.7 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' @@ -625,7 +625,7 @@ describe('lib/browsers/electron', () => { it('newer MacOS user agent', function () { userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36' - return electron._launch(this.win, this.url, this.automation, this.options) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { const expectedUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36' @@ -752,14 +752,14 @@ describe('lib/browsers/electron', () => { }) it('.onFocus', function () { - const headlessOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: false } }) + const headlessOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: false } }, undefined, undefined, { attachCDPClient: sinon.stub() }) headlessOpts.onFocus() expect(menu.set).to.be.calledWith({ withInternalDevTools: true }) menu.set.reset() - const headedOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: true } }) + const headedOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: true } }, undefined, undefined, { attachCDPClient: sinon.stub() }) headedOpts.onFocus() diff --git a/packages/server/test/unit/browsers/firefox_spec.ts b/packages/server/test/unit/browsers/firefox_spec.ts index 106661b6f1ae..936865e654e4 100644 --- a/packages/server/test/unit/browsers/firefox_spec.ts +++ b/packages/server/test/unit/browsers/firefox_spec.ts @@ -530,6 +530,7 @@ describe('lib/browsers/firefox', () => { targetId: '', send: sinon.stub(), on: sinon.stub(), + off: sinon.stub(), close: sinon.stub(), } @@ -548,6 +549,7 @@ describe('lib/browsers/firefox', () => { expect(CdpAutomation.create).to.be.calledWith( criClientStub.send, criClientStub.on, + criClientStub.off, browserCriClient.resetBrowserTargets, null, ) diff --git a/packages/server/test/unit/request_spec.js b/packages/server/test/unit/request_spec.js index 550b752cc3b7..cf46a763b03c 100644 --- a/packages/server/test/unit/request_spec.js +++ b/packages/server/test/unit/request_spec.js @@ -389,7 +389,7 @@ describe('lib/request', () => { 'Content-Type': 'text/html', }) - return request.sendPromise({}, this.fn, { + return request.sendPromise(undefined, this.fn, { url: 'http://www.github.com/foo', cookies: false, body: 'foobarbaz', @@ -441,7 +441,7 @@ describe('lib/request', () => { 'Content-Type': 'text/html', }) - return request.sendPromise({}, this.fn, { + return request.sendPromise(undefined, this.fn, { url: 'http://www.github.com/dashboard', cookies: false, }) @@ -583,11 +583,7 @@ describe('lib/request', () => { .get('/foo') .reply(200, 'derp') - const headers = {} - - headers['user-agent'] = 'foobarbaz' - - return request.sendPromise(headers, this.fn, { + return request.sendPromise('foobarbaz', this.fn, { url: 'http://localhost:8080/foo', cookies: false, }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 291da09e8f94..d5b7cb1c98c9 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -76,7 +76,7 @@ describe('lib/socket', () => { done = _.once(done) // when our real client connects then we're done - this.socket.io.on('connection', (socket) => { + this.socket.socketIo.on('connection', (socket) => { this.socketClient = socket return done() @@ -161,7 +161,7 @@ describe('lib/socket', () => { }) beforeEach(function (done) { - this.socket.io.on('connection', (extClient) => { + this.socket.socketIo.on('connection', (extClient) => { this.extClient = extClient return this.extClient.on('automation:client:connected', () => { @@ -411,7 +411,7 @@ describe('lib/socket', () => { it('emits \'automation:push:message\'', function (done) { const data = { cause: 'explicit', cookie: { name: 'foo', value: 'bar' }, removed: true } - const emit = sinon.stub(this.socket.io, 'emit') + const emit = sinon.stub(this.socket.socketIo, 'emit') return this.client.emit('automation:push:request', 'change:cookie', data, () => { expect(emit).to.be.calledWith('automation:push:message', 'change:cookie', { @@ -765,7 +765,7 @@ describe('lib/socket', () => { close: sinon.stub(), } - sinon.stub(SocketE2E.prototype, 'createIo').returns(this.io) + sinon.stub(SocketE2E.prototype, 'createSocketIo').returns(this.io) sinon.stub(preprocessor.emitter, 'on') return this.server.open(this.cfg, { @@ -814,7 +814,7 @@ describe('lib/socket', () => { it('calls close on #io', function () { this.socket.close() - expect(this.socket.io.close).to.be.called + expect(this.socket.socketIo.close).to.be.called }) it('does not error when io isnt defined', function () { diff --git a/packages/socket/lib/browser.ts b/packages/socket/lib/browser.ts index 5bcc7dd07e28..e6eb5c5eaa95 100644 --- a/packages/socket/lib/browser.ts +++ b/packages/socket/lib/browser.ts @@ -1,12 +1,39 @@ -import io from 'socket.io-client' +import io, { ManagerOptions, SocketOptions } from 'socket.io-client' +import { CDPBrowserSocket } from './cdp-browser' +import type { SocketShape } from './types' export type { Socket } from 'socket.io-client' -export { - io as client, +const sockets: {[key: string]: CDPBrowserSocket} = {} +let chromium = false + +export function client (uri: string, opts?: Partial): SocketShape { + if (chromium) { + const fullNamespace = `${opts?.path}${uri}` + + if (!sockets[fullNamespace]) { + sockets[fullNamespace] = new CDPBrowserSocket(fullNamespace) + } + + return sockets[fullNamespace] + } + + return io(uri, opts) } -export function createWebsocket ({ path, browserFamily }: { path: string, browserFamily: string}) { +export function createWebsocket ({ path, browserFamily }: { path: string, browserFamily: string}): SocketShape { + if (browserFamily === 'chromium') { + chromium = true + + const fullNamespace = `${path}/default` + + if (!sockets[fullNamespace]) { + sockets[fullNamespace] = new CDPBrowserSocket(fullNamespace) + } + + return sockets[fullNamespace] + } + return io({ path, // TODO(webkit): the websocket socket.io transport is busted in WebKit, need polling diff --git a/packages/socket/lib/cdp-browser.ts b/packages/socket/lib/cdp-browser.ts new file mode 100644 index 000000000000..71fb3f39ce78 --- /dev/null +++ b/packages/socket/lib/cdp-browser.ts @@ -0,0 +1,72 @@ +/// +import Emitter from 'component-emitter' +import { v4 as uuidv4 } from 'uuid' +import { decode, encode } from './utils' +import type { SocketShape } from './types' + +type CDPSocketNamespaceKey = `cypressSocket-${string}` +type CDPSendToServerNamespaceKey = `cypressSendToServer-${string}` + +declare global { + interface Window { + [key: CDPSocketNamespaceKey]: { send?: (payload: string) => void } + [key: CDPSendToServerNamespaceKey]: (payload: string) => void + } +} + +export class CDPBrowserSocket extends Emitter implements SocketShape { + private _namespace: string + + constructor (namespace: string) { + super() + + this._namespace = namespace + + const send = (payload: string) => { + const parsed = JSON.parse(payload) + + decode(parsed).then((decoded: any) => { + const [event, callbackEvent, args] = decoded + + super.emit(event, ...args) + this.emit(callbackEvent, ...args) + }) + } + + let cypressSocket = window[`cypressSocket-${this._namespace}`] + + if (!cypressSocket) { + cypressSocket = {} + window[`cypressSocket-${this._namespace}`] = cypressSocket + } + + if (!cypressSocket.send) { + cypressSocket.send = send + } + + // Set timeout so that the connect event is emitted after the constructor returns and the user has a chance to attach a listener + setTimeout(() => { + super.emit('connect') + }, 0) + } + + emit = (event: string, ...args: any[]) => { + // Generate a unique key for this event + const uuid = uuidv4() + let callback + + if (typeof args[args.length - 1] === 'function') { + callback = args.pop() + } + + if (callback) { + this.once(uuid, callback) + } + + encode([event, uuid, args], this._namespace).then((encoded: any) => { + window[`cypressSendToServer-${this._namespace}`](JSON.stringify(encoded)) + }) + + return this + } +} diff --git a/packages/socket/lib/cdp-socket.ts b/packages/socket/lib/cdp-socket.ts new file mode 100644 index 000000000000..f042cd14b81e --- /dev/null +++ b/packages/socket/lib/cdp-socket.ts @@ -0,0 +1,174 @@ +import type { CDPClient } from '@packages/types/src/protocol' +import type Protocol from 'devtools-protocol/types/protocol.d' +import { EventEmitter } from 'stream' +import { randomUUID } from 'crypto' +import { decode, encode } from './utils' +import Debug from 'debug' + +const debugVerbose = Debug('cypress-verbose:server:socket:cdp-socket') + +/** + * The goal of this class is to emulate the socket io server API, but using the Chrome DevTools Protocol. + */ +export class CDPSocketServer extends EventEmitter { + private _cdpSocket?: CDPSocket + private _fullNamespace: string + private _path?: string + private _namespaceMap: Record = {} + + constructor ({ path = '', namespace = '/default' } = {}) { + super() + + this._path = path + this._fullNamespace = `${path}${namespace}` + } + + async attachCDPClient (cdpClient: CDPClient): Promise { + this._cdpSocket = await CDPSocket.init(cdpClient, this._fullNamespace) + + await Promise.all(Object.values(this._namespaceMap).map(async (server) => { + return server.attachCDPClient(cdpClient) + })) + + // Simulate a connection event + super.emit('connection', this._cdpSocket) + } + + emit = (event: string, ...args: any[]) => { + this._cdpSocket?.emit(event, ...args) + + return true + } + + of (namespace: string): CDPSocketServer { + const fullNamespace = `${this._path}${namespace}` + + let server = this._namespaceMap[fullNamespace] + + if (!server) { + server = new CDPSocketServer({ path: this._path, namespace }) + this._namespaceMap[fullNamespace] = server + } + + return server + } + + // We want to match the socket io API, but we don't really need to support rooms, so we are just passing along the existing server in this case. + to (): CDPSocketServer { + return this + } + + close (): void { + this._cdpSocket?.close() + this.removeAllListeners() + this._cdpSocket = undefined + + Object.values(this._namespaceMap).forEach((server) => { + server.close() + }) + } + + disconnectSockets (close?: boolean): void { + this.close() + } +} + +export class CDPSocket extends EventEmitter { + private _cdpClient?: CDPClient + private _namespace: string + private _executionContextId?: number + + constructor (cdpClient: CDPClient, namespace: string) { + super() + + this._cdpClient = cdpClient + this._namespace = namespace + + this._cdpClient.on('Runtime.bindingCalled', this.processCDPRuntimeBinding) + } + + static async init (cdpClient: CDPClient, namespace: string): Promise { + await cdpClient.send('Runtime.enable') + await cdpClient.send('Runtime.addBinding', { + name: `cypressSendToServer-${namespace}`, + }) + + return new CDPSocket(cdpClient, namespace) + } + + join = (): void => { + return + } + + emit = (event: string, ...args: any[]) => { + // Generate a unique callback event name + const uuid = randomUUID() + let callback: ((...args: any[]) => void) | undefined + + if (typeof args[args.length - 1] === 'function') { + callback = args.pop() + } + + if (callback) { + this.once(uuid, callback) + } + + encode([event, uuid, args], this._namespace).then((encoded: any) => { + const expression = ` + if (window['cypressSocket-${this._namespace}'] && window['cypressSocket-${this._namespace}'].send) { + window['cypressSocket-${this._namespace}'].send('${JSON.stringify(encoded).replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}') + } + ` + + debugVerbose('sending message to browser %o', { expression }) + + this._cdpClient?.send('Runtime.evaluate', { expression, contextId: this._executionContextId }).then((result) => { + debugVerbose('successfully sent message to browser %o', result) + }).catch((error) => { + debugVerbose('error sending message to browser %o', { error }) + }) + }) + + return true + } + + disconnect = () => { + this.close() + } + + get connected (): boolean { + return !!this._cdpClient + } + + close = () => { + this._cdpClient?.off('Runtime.bindingCalled', this.processCDPRuntimeBinding) + this._cdpClient = undefined + } + + private processCDPRuntimeBinding = async (bindingCalledEvent: Protocol.Runtime.BindingCalledEvent) => { + const { name, payload } = bindingCalledEvent + + if (name !== `cypressSendToServer-${this._namespace}`) { + return + } + + debugVerbose('received message from browser %o', { payload }) + + this._executionContextId = bindingCalledEvent.executionContextId + + const data = JSON.parse(payload) + + decode(data).then((decoded: any) => { + const [event, callbackEvent, args] = decoded + + const callback = (...callbackArgs: any[]) => { + debugVerbose('emitting callback from browser %o', { callbackEvent, callbackArgs }) + this.emit(callbackEvent, ...callbackArgs) + } + + debugVerbose('emitting message from browser %o', { event, callbackEvent, args }) + + super.emit(event, ...args, callback) + }) + } +} diff --git a/packages/socket/lib/socket.ts b/packages/socket/lib/socket.ts index f9a07afaaf95..0aed1ba3b9db 100644 --- a/packages/socket/lib/socket.ts +++ b/packages/socket/lib/socket.ts @@ -32,6 +32,7 @@ export { SocketIOServer, } +// TODO: I don't know that this is used anywhere? export const getPathToClientSource = () => { return clientSource } diff --git a/packages/socket/lib/types.ts b/packages/socket/lib/types.ts new file mode 100644 index 000000000000..d5212cd6b453 --- /dev/null +++ b/packages/socket/lib/types.ts @@ -0,0 +1,3 @@ +import type Emitter from 'component-emitter' + +export type SocketShape = Emitter diff --git a/packages/socket/lib/utils.ts b/packages/socket/lib/utils.ts new file mode 100644 index 000000000000..6baf01b3c034 --- /dev/null +++ b/packages/socket/lib/utils.ts @@ -0,0 +1,47 @@ +import * as socketIoParser from 'socket.io-parser' +// @ts-ignore +import * as engineParser from 'engine.io-parser' + +export const encode = (data: any, namespace: string): Promise => { + return new Promise((resolve, reject) => { + try { + const encoder = new socketIoParser.Encoder() + const socketIoEncodedData = encoder.encode({ + type: socketIoParser.PacketType.EVENT, + data, + nsp: namespace, + }) + + engineParser.encodePayload(socketIoEncodedData.map((item) => { + return { + type: 'message', + data: item, + } + }), (encoded: any) => { + resolve(encoded) + }) + } catch (err) { + reject(err) + } + }) +} + +export const decode = (data: any): Promise => { + return new Promise((resolve, reject) => { + try { + const decoded = engineParser.decodePayload(data) + const decoder = new socketIoParser.Decoder() + + decoder.on('decoded', (packet: any) => { + decoder.destroy() + resolve(packet.data) + }) + + decoded.forEach((packet: any) => { + decoder.add(packet.data) + }) + } catch (error) { + reject(error) + } + }) +} diff --git a/packages/socket/package.json b/packages/socket/package.json index 592fdbe16650..292a1755fb18 100644 --- a/packages/socket/package.json +++ b/packages/socket/package.json @@ -20,9 +20,12 @@ "engine.io": "6.4.2", "engine.io-parser": "4.0.2", "socket.io": "4.0.1", - "socket.io-client": "4.0.1" + "socket.io-client": "4.0.1", + "uuid": "8.3.2" }, "devDependencies": { + "@packages/types": "0.0.0-development", + "@types/uuid": "8.3.2", "chai": "3.5.0", "cross-env": "6.0.3", "mocha": "3.5.3", diff --git a/packages/socket/test/fixtures/cypress.png b/packages/socket/test/fixtures/cypress.png new file mode 100644 index 0000000000000000000000000000000000000000..59d099f5179ce34267bcd496dd0cf0a6d558ba6a GIT binary patch literal 11881 zcmaKSWk8hQ)-N3bQVL2AUBduFNOyM$1AWmAkrPuFoY=G&46?xQgVm? zd(L_9hkNdPnAy*MRHWZz$+mT55FKUm>&oLbMx?XaSL(raC36=2=j0YgSi3!`p_e_xmZ{VYs;QFsHAp^z*Gj-zP0dYUN^lw5nwf{e< zz5RcvUEQ>y|7-96Q?RS9mlKpr8|v!l?qZHioF&7fDkotX7pSS5ql>Pi<$y!`)__pu=CosgPk zp)NKaPz!k%M|;3OjtkrTcU!>!Ro=gNE&jVLy#JM#3uz43V|xFubpN{xSwoM_{}eCs z;XmaMbwJj<3$nOrsvW0LP}pacWTkXr3kQDK>Cfe6e$x$eTO?31sl3GyfBvjjdFnM; zYIWgHOx4oTaFDmyaYdZ9ao%EhEukE zx%F5TIzInYqSZW|(z+u2ngx+BsIb^b&3;}08sP^{L%vjhv~0;N!cdV)%}4e3j@rT> z;b97g{H`smJ+PP`u4Q1eIg%s6!3d&?Gjf)v+WIq|w1t&g3?v993T3mXes5;boAkZ* z(-5>xl_O9RV6nk@_Ce1XPlYno1ITZrSrH7xGe|nIa1mmjR0R;>WHiT1lKdG@ETUBL zNks!y9x6Uc!ZaZ5A8_#Yi5u$;kP~78m-KNLt5Q zzNGa()VBsXmKg(uE(WbKkBk^B#7oib11?qgGMc_4;19Ua61PxE01>z)N%3d{e*0`S zZNC_W>-LwyKtw7%5l)Ee9V6O90uS)MXrI%D%`aW^x03#2NL83s8Ba(v`n6SBm%(v$ z7naf)9L97+I4)Ef+=*^1oxeP#c3te(O7Zj@Wzuy)N}fojW)@QDnMl%>^RDgY2akzt z+^vs~$|K%@wTS&nYL+;c-&4-ixh42K3zQ^Ev*?jUgZFv?XO+kopAspFFn=VNc#akb za2O1UEJV9@^_ogMa>rLl39D6Zl?D*Oa#Yh5*^?v!6x??W;m%1#1f;`HcAo@Ft{H%d z>75Dkmbyb~wf~`vQ=Ot7@?=za}0n3`bJ*oGSpG33rWNUbEo z!B7V@F(-D#TeK2I?te(PI`{I8QbxLuHk*uV&NpH_5nCy1oEpHJhe&y(u4E#8Tj+ge zp5T^O?#yw`fb2pGtQa&;RHQiDZ;-d}`ldW{SuSAi*K8S*IPvj-}l#o&J0k8M(RNegvcgtcKkJ)-R zL4dUVsjTn!ep&4#+AN5&N|P5Exj`li)2K1SV?NA^GTNDCH@v#m%!1PK@GyeX6L7i2 zn@$Vj-Smw<<3=hqNdmZIs^LbqpM|z^D^Dpg~nMTDJAGtqGsAAzH_EA5n!%ucV zx)W#3{58d7e7;{>yOsUODWeG2j%B~-(GgIOai2Ib9zEOJcERZ{uanvJD^<5f($4Qa z&CI0OA>89vSHFH_7oGp&8XHPx*&R$Q0EXNA%=uRDzAqr`zV|W47C{+F2lVtQ>S=EY z4D%Jkqvtm2)sDFn=i5biei!Yj&4%KQoWUY8zuT*?DcGfMBYr;DmA!wy5dGVpxs&vi z+2I)4dkm8FPjX!?z>23m5ututYHo!8VQ;c!9yU@;=KWFKJp3W!}4q5?o zDN9HEDg;#O)sPFV%6wkff}JqR$`Z@b$^w_IVFQreYR7%O_oeO4die8t(_Z;j-)omS zPjVnc(=qzzv7&}XQ$xI_!qhZ7$f#M;>SqqYg}$1*YG?|BMSf^*&abPSxX-@8&xF-o z@G{$v$6etMm?J)SmDynuhV}DNq&rHLBSkpFaQZ7om3W+6a=fKn{CYgHEMv%#sm+i} zN`Jmneoe#{Z)(3$Q#1%-Efr;KV4^w27c}a|3V8h+oazNsTI#5O(ZHrr-+(3Ht%}KRhwa zXCx!$&x{Y+TZ|kOVt*Tr#9KlB4kWE!lnkDcjVv0$-TS%fINg{l8hPgxm?eF+a@*NRgsa)= zW&`TvQp*udZ1p^r$9(b(r}Xt#qq4Lufp;Cgy5)MDi_GeW3k~%V7?8$;Ta$q4KzN%6 zzQF60;P2n^fybw`vw02{%#_xkh7(6b?Yl^4NQNyP84i_-3HWbvK4DL5_eQ>M<`#qd z_wh+A`cuCsAlIg1=<21YihrBPOTAKH{26UtVuvYjV#36e;_8F-EAbL_j{|Fc>eQFE z?cR%uj3||YYx31Os~LS0!nR<#P7NhC5nKIEe=!P)zs9tYjLgi!XP3;3jGD(QVm3V? zSEXL(mii8fX%C<8L3@ZCEQ+|{;S3rxgxRa{ z1o-UUvFOJG(v2aj1_D@<)wV>j2+E0Q?i!w`P`1vAzX?nYF#Ma(aeD!qSua4OaD#F_ z#rbDZ?BH9j*|Jc}EGM^M^ZEw`?<-N}54Xk7DksZ<`k{=Zf3YhDbBlhyaBAnX7{r?@ zPahjc5Z)N61T$kEQYv;fOurp zocmeI^e3MP+T@}6JHhl#CH5le?}M_&pMT6Sl0c~J}#xRh9%X1^3jepgBh8HN03uipOjzO1}l9)k3D2ZvkV{*IVP?=y2Q`<_g! z0Ca>~Q}Ag=YXJF4EqbP2+cn9WxfeuWqzX4R@96j@Y>635YZ?BCMyN|>!9w%#m5TUC zV)Wh98p{(G61G}`RHr}{(PgjJ)e&21;eUMPm$1@@JMMprbUs{td0oD{At$!qe{(sy zn$=}th^@7RJ^ky{aqom%lJbUoKqs%fRmAC&>Ys13<^8(krpGE%MOU3e$5k5wT?>0m z1oSo|@mdInE}=i0tP2=unY(86Lw!0KmaFspr0HbL=`i#oMJz)939CqiylOi4DWS;k z$r6ppn@cuB=z=ruYO^y##cIIA{nhy0In7hT-fTqN)Xyp**iuTuR^_(43{>9bT~PL9 z1?cTZlP&jnO!{u)RSTED(!yW9aj2|X`wCPm*44WRC3fL^Zy($FH+CL4>~Z?tT(~Tc z>oGeO^85UrMSbXe>cj{=Q^%1<6K00P<7rDc-!#gfCxeQAREc3hLPR$UJ$ml(_tPJE zbXUT-@j$Bnw_YYx;<^eV<0w~*P@|Pf)ApUp=pX>c$)-?R)2_8gg|KHT)*5|vMPw77 z?Nmam+wQ?IjpUyc9`hBPotITA%%M-7=OcsrqTKGA_oW|2&TB>%E<#FF03~~(L|@1o z=b3?8?1e2#q6k$8%`3>G*5pW$vUeXn&;zKATBR92giUMOORtyb?|;P}sjblhwgM7w z@#6`4itp0yW^{2UCNSNa`Xu!pz&X?JN8AuFbT!;G)*;-;H zbJ=s?KxoB&&lL<*GHfXglf>o>!~x+!>PT1o`AHO>g=tXtSB_E=Q8~2c4WHFLJ*? zrcP}h=V0_B{I1fG{!f$Nkau(1S4dd|i55m2HXR7#n;hsf<!J@q zJ#L?P&K7B^dtlFesSZxxneCUE09MlH46L_=@Hc^jh1roA%MJUhrK&G^4Sz+Fvg{yz z&6kVrxa7R(MoC_3b>G)S&IEpzo!w^kY8l`Z0sANRQrEqN64r^hpmpRtZeDmVoOX;u zo1A1e#71$x*8d_I2koG{m}dPI$l@OF`K)bQQskY~=MK`L7}o_qqZP$TR8sV{x4}?* z{2Ff4?nxo~F8aCPTXQ|ehxCVKt?BSNR1Nz5xDvtEs1Q}Rc!}z+o)QP6W*&XnR^1^d z2Zm3r55)q2ZmRAudDr$7s7@6nA#vvP^7=+-nShnQ3h%R)rBX$Ts}kHqDo11I@6e5i za@k0dsj}n@K)p&Tu*m=ZsNf~9IGz$?$4lo^di$k6q_LD&G?xeSS>iW4=EH-3%uh!6 z?PeyvXRLQdZjD^VwW=Dpbr%}Kr(pe2&&Acgqh+2JMnQ*BtM7AR`SGLbj8ft_7=^vd z!I7+$(}OR4{FCLe^E~jm>{9Qo3uTk+Rh%2y@o)zzDBtPvn z35quxQ`179Uwi6#9xbt% z+?87g$_4y6NxWEe?3BBI1FO}@74KD$ZRSOB;uEl)vLjxmJ(#b9lY7mv6LHs?^+zrE zp~QP<7xWx=YBb(VeqUAse%Nf-_o{V>KVy(&_2@eVJC~e!x{y0n#wM_g=sq0eOXRpa z;?&LsnK;hCj?s>0ohdp4V(->XznYn(cUH-GlNrsKcAA=fXwL0`+Emm>5!m|P$k)Dn zf?}Loz|$SKm;Zj!IhFi5SB$pdo>gF6Q>`8ILNvCo@>tDYl?} z5J-3>BZn2*V()I?U}n>e7H|Dx#6%%xj1Dj8V;*Jc<;&529NU^WxruC3K8xi!TL~BH z*s$yEvyNcB(Fx^I1&*=U$mS)dEPrTV1~|@0`bKt(zX7&ZNpIKA!67;L$8(k1>Z07J z`6R0}0KL5ONA`p5b#`-zhO*)3*N)k1aUA-UXQ2_}LY7Xj(>~BCmtG_zp3^)&2U|8w zXF5S9bpOmXCITZ)%1?UhotDVTGp4|L5{TjM5{obV7-DBaCv750^~MnQCx$sdP&1zJ z?5ec}@nen$X8Ya^AL16D&ime0e{PB(BQvt`*SUSu?yZbk&RwEMmmKL_yI1tFHBc{8 zX*^%fZ~ZOMQSG7t@T!qSK@t64%|Z?Vb^-~HO8S{?EnNwTGoeSe3n%74w-vA!`BXNW z4jCYAz~k$|>@oge`xoU=zw}=HF>D%$Kr^bdB&dj@5``s@alPG$8lM_<4iaT28mW`& zjl#oi)vNle)r4pA^q207aIbclzfE;}2G+1OMX*gcwIrd80+%pdqLU~;+@y`n=C08_ zg6OEzdZt`B%@4bUu=TK2LN5+(a`gom>i)!LTv}r3=iN14zI%1u^VT*W>O)z0Cx$Eu zqgd4&PeBD$Lh5U^=x?&jyTuW*TLQB!Fol~{bF9U%c6vRD_g_`IKUrT++LKkfAGPK>LnGTvyHs?dZ8*S)PUQ1uJ< z^sEYT7LCC=;T(5Hys?3as@s7Qx14fYRS(E2@yR94|z(9d;ovCv1pY&n7D&5b_0K{Qi_EqoV*HTUF}n`}|Xm zMjc^8vxv^yVH0ZN#!bvr>h=>5beFtf>IXNMxJ|XFY{UmsxWB{CEa0m-s8O&qhn=7E z>`s8I!~+!v$M>-@WTshr#?hp}71F(0JVflto;4#3hj8N_?68q3g6`_YFps?hC+V_X z9i@j-4kE)cXQ4qnt_eIsv$Bo8zO7hHkf=mWo^?7^?-fpTpSZ(M7$T6TtOmh6dNRt~ z{*AbAJhlt8YUax^Vi-0(;`xH>E5!7oxee$=eoOFCYa!4ZwBB#tS=OVAI9AR?G|nQ` zS-;(q0k)U!!v*39XB03mGh=$08*i zj{IXWNsv}ju4anrlM7#V6LJ4!Qq@CGPA@xnj_@2)bu($FVDlm?9JvhAAf=}|=DsEg zA>F7=6fj9Zw-4%HdY59fcGdII2RxECZb-g8eQ)h{#|Cyr3=+F=NHPNTjb7cs=GOAs&Odj4FKHSKB^+J2fj6mp5$K@lge zmUiKYqY+bHY;tV6c!OaZGcYl_=Z72DoRpk=XzfpT21vN-ljQNpiu(Huc=H7-O2?P} zUHYl~lORuXh=iidm1d1-a&thsRMj73@YLjCTgMYD8Y$zY_Rp5VS#>_+7@DMaSGo<5M`x zY0oWxbR#M@>9AfA6IHxv*5~(t-FX4%dMd}WjGbLMuXEe`w{XK@wjEjG%6Agr=t|_j zOM573MqO`d`(&47XXSEEtx(0Dl{Y}0`hAKLk%C)Q^Jigd1A&vC#Zk#Nlh?r_C5Jo? zqyp^KPFV#c!5J*ciW6I5cN;%*T<>K=8|MGs&s09Oo%#Awiw`PRb@kkX{~N%Id>2%e zOWiiw{wz0Q@K~D3vu7M1i1W0+`A74wj30Q%<*ljjmY8MVI&7nj;z^#2PA?U=4h+eX zE4J)oLZ;P;=sr6vpIkA=QogBs5gZ>EJB~gv)HcS{!%Or295YHocB>B_eJtv~rntsg z3nyfvz}LGRo7PmXzR-)lPJPi-#?H5Y5R$LP+$x26zI}<6v9p{N@|&So?%V6pJ`*4) z@M;L5{V7gvisph{2XLfiLcq^7AbU$hMXUC#a6;F-=FhuSYM%F`ajisOieP%+aToQm zhQ8r6u3Q$&^X<{Bg5jC;=j$78bc||=^m4$IZ|@Civq#0%Rzi=m;kbm2*NpwNKX(5J zcVmYXab1rVKge0;GeEi(-@^8@SaG^l4Vceq?UEmYXiT9$;GfmTG*?EInGHp%75!L) zq-H6;v+cmrXDf}f&VCX4zp}lI;Nawp{e84-XH^%#Kq&1Mw7)_N8#kDYUiip>F%*4_ znwmD4TLLF9&b~_w&qnhYb<6}3l_eS^K}@9atA3**e##}Uo3yo_UWKuub`U;o!2`C{ zv0LLA<^oEJE!{uCNh~nBuS)*=9D`~WRm8oVIQ>2D+zvbi^ccvk8TxZ| z+72#i-8S}lQTYg*YQCx?gFaX&r@nX@T_@6^zc>))M< zZSk7x4n)0zOc}~^f{!(`!G|j)*FNc%KfqM|bJLPLBxmpyir#4MADK08WUUiB6N+U# zi7u=ROkK08d2`DVQV#kIa_nM5H1RhAC*|EdX{or&-N83xu&v>g^S-|GUxGEM(sU4v z^}XqGojmDrkxW`)w}?i^rpv#FpTlQyNQKxue5#jmD7@w|{eHbR6B+_1WB#Sf=q6G- zo;&got^hSziH&fR9o)Q0c*yC!$X*izkX9so^ZC7_a95#Mk!i*J>9swI1<4@(!XB}{ zOYNbak&kFQ$jI(m^en@!?zv{Yss-Rf@VW0_=toK*^H_NJ@8-<$C_7pT=V*V0e-+66kaPRN9v ze-^=A1}2Y&C|5#0ke_b~gFORR#xU(~QZR|r5>@SU1L-M)RqN+WkSC=db1Hu=hTUQ; zR|D0hSl~*(1&zmazZPDJ`QHiJue@(tolxiU|6{-C@!_T5UyWw2(#<$cT_~=*t z9q)>(jkU5&ewxv>c<=dbEI&ZW<;;a8Gh<^s{##ujn9K)O^G5E*bi@bG3d0yy<2s{8 zy*6N;e(Z)fSaDGKYVR4EmH2YDOElSNnLL5xizLXXGg^5C`|H14DfKBMo`R8kg(PuA zf=zexcC^CBrNr^&X_h=rNpF;BLZfopH0#n)m(oHNiSTbXtJM}z|ANh3ZL*2Yo%w6J z#LfnLb8zph`StXB!~AneV0~{+e+KHg?Is=4rO$M?&E!?SAH+*5OBb%zX8U3=AnI`I z70-n~$rliBg!5Cl9(B;J`l}@nld81QVWa+Cfwd0W!d9Sw@h~H){p9CRJaU_+V#`qq zw}*T5m`nAcB&MBtD{P(cr-w0D?bED18D8uAQ@GS-OoEX@E}xDqf<7_>We9+b&+$1w zVf2Qi7V4WHsdSchmChwOh?irzG_$2>w&vs&;;m-58dZ}Q* z4Cnehg0IO`bWFpKFIs7+f|*uYp>XeG$WC{i<^7`K=22@oLoAJ0&o62k0uoZvGM$n( zn==GqnMUs0PL`sehPh-7ann1T(P1t9(~W-LzL`R$i?m~Iw-4kUqs>(4VAPdurF-A{ zm2O-8=t3!GRYMkU!4Opsli4cD%a?iztFE-3;Ckx@OR2D-4B+e9yJ^7R2TwyFX`UuFo+ zT}SIosaAR10uyB5M|RuJ??uN=Ib#W_cV*f|rGB>_9X^-{Q6-I^C)cn$)BQe#W{0nk z?MnMg_rrxy8ovwEzpk$D)&@C7ICgnSxV^k$jX<7Dr@u-A!oS7_{5?PHXf_%*S*X|W z^%oN`=jSRV7O^Vxnj9T#lfrrBno;g~Fz3%0M2Olp#x+E%p+s)7n{&59O58|kKf{xk z-E*wmU`Nz);8w{X-j~G}E!|#OX*81yX>@ME&Nuq{9{EwV=QzX`w;BpFe)u78sCOb1@UE_>eV-N)Z%c&(xm(}8 zBWfxUA0bX%UIKC0vlsBqy~QeC>TRig=1YFPxnY+F9iiN9`IS?%g&z1j_!E8ehp=Sx z8ngaq$ITa_5hR?&59>tY0Y}a8Vq$ z$$9B5Vm)srf-5~IAZyd3W?Mgd+M#&MOF;I=4?y#Yg<(oYN6umnAnUz^5&Opynn*S@ zjt{bZBVQMi@bDHY@RV%O(b|QsPei$lN8V|GEbQi3!2g#g-HKPq0zj^AQdOt(nryRY zi}s0@__GL0TlBg3i}SxIBY(1AL0>;;Cb}xi)#HcAy=!qv`8hSqCkYsXd`0UE-mDBn zr{$?-wb~Z6ncR*m^(W^{w4iYol4L9-JB6TQj7uqPD>%&qoGq~PzdkJzL`Nh4(pFl9 zPqrwlG=sTElA(ker{cCmAW9pi2<9q&Bi|Nc!lS=2Dc=IL4i}lG`k?p39-Gl#?T zr_{=m_S##0pdA>l+w0nyxLU)vZ%~<)7h$OA=sA~&-5^>59rTne++{9djd+KV_b&F_ zQGPnjYK9M$D5Ap)$Et5x)evNq5g1u{Qgji8p!;0nKv4A?`^p70g2M8JbpDz^#qD#d zH$>JXs;{(ekcVp4bJgn$Y<`DmM`lhx&18S+3#lJq+>G0%!dZEAgle`KyJ{_EKQ*07 zozZ%Fp+b_yxBDC166DCq_X%N6`10pX4PeDAa4e28?5kQtJvU%3t_Ly@p6UreYzk^4Qm@XdG2aPxS}x4)xDI3m`q4;#%UdXJUnihb3qbf1{x zFh`S;>~9<4b0tgI!JDRwK)^=nF71cZS#Q;9MX>m+T&_&A6eIN#U`b#ey z6nI}>RCy8|C{#b%RrIs|1pzkMMHBVPYq75LJF($FO`)I8rp<%U*XT-XXzkRqr%vet zM!MVk)^Oo85B~Pq2P)h|3x-?EDAr}zMHE9tR&x%j%u~#|9@wNCXF?(|b4#7o&&c^2 z^7;a$e@7DeFbF^#F$bl}xmZ%6W?aE=T2)Dg|Ei}=Kj zwD!ns5ZQ{bQB{0C56XH#n>uI5{gm`CEWnYTLgk`lsw>&Rc00XtNJPd_l7Oiyb1mOm zE2h{FTzO`l!LYh%k`1#(!vp6Yz?yO_7)wXXr!?XUC&UbLsP zR`tW2+r9?@V#Oc_^bJLOe=KNl!M9X&`U$)~V4(nukkH@gsT8*BVwN@3kC5UbWO+KLHyNQ`E+Mdn zDiON6`bx#C8u9YSMDiW#ky}mlopWY!2A|LGAZ*`{hh>Fin?6bs7K*GUHSqf-*&@0O z2W&Eu7Zexz8f`KYRk{|UuV3QK@r!Q_I)*LE5SL_kiEUc}k6p6pX^odrHu4pVKO8s=MS=_ho4e_IYc`_#O8 zzB=P&Sg?_Qy-T=(DJvK-f2AWDHzgVQs!7Mow-775i;*WXcquFc3XbZ+FX;yD8OU9k ze7K{7RKIyprdAW0CWHBH3KHh!o4`!WO42HEFmMOg(k@-`2XN?qA8Ty}n~RguX?Y|_ zTw-j*bJmlb@ad;3cMiq}2+eC^alVc=?{KPzxI1zQ* z;ieY%og=q!S!_J44|w>SzC5H`VHi3P>jVEjPui5HU)3Ssa}$mte$>X#(#UC%ROD;T za}-l}#m?nzWKosMMMs*A!WW$~9pZi6O4 z*fXXYV*GPIzHAiO^dwt<+qP&-QAJ`eUJ;$;2T8b8&*M#~CH6k9;33r}dCRY}OH~+= zfFL5`4A%ClI6@5Ku8>IXBfo@}33-#`lTR_Jk^rX|)v4<>!0wm9YMV(*+Lt>ffA1LE z2(aHiNjXQtf^ZJTR3%xfV>0NMpVMICkxfO>$E^CF_Un7-5vTH-Lkp4GH!bo}!ozzf zpxuE~7rkNMRH4HkYm5eF-~G%>X0gZ9$4s90cc_&o#=#JZQT-Uc7d!zC)GAaYrcIox zv|rA};b|7XcPq}GrmEc3K_ZSYnE6PFhJPV6B)JBIbZ%+4g_#Z5!`7n4$&(;N(CBsM5Q zH9@l6e(`Dcb-Yul;-C$+-s1`lc>=QRz0WBQ=Bb^i699YHS19!n721cZdLmIn%%d0~ zNj)J!pUrhPTU<#+r9*b&74ZJ z77t-4RU)rrQ!T&$`CiE$thjI@`1PX=ClQXQ3bO&umSt2hL)HB);fqQ6?e7^XgreLD z+S>1RUL>ecD!k#aP)5RzAb_e}G%u;e*+%>DH`np!kjisg_`z~kvGKrgO1bZhp~S=c z@>;Ct%5jYtXi~n3k@O!KR9VL~O|HC~$VIS(lqM!8{phv|P+cZ_9WhK1_RPd686+Yf zx}fYx>CFtJa;&@T2g8b~@NaWM&9BM~czp+-G1eL)K@vA;~0QdRa7nT#&&F_rA(RI+qcFc@t8IAB0W^cL!ENJ*8RWQ+OCl-YLk zY(Z1$mO9gnRze2@l8Y^>lEe)Du8KSK3SHx4qR}NQDnW%wpMbWkYNpPVe|;;2Pv%GuZZzqLsGYE=p}f0-N0=-bzQDaw(XE}6iaFfx?JlgD^w zj;|81ds7`@0HNs9>g**qM?xGC|AsgUA@8N)QGvz=Bw};3 zin(}JGO}ptRr%7)7Q)T55yVM2TK=RC0atb#=13?;+fQRLN{CkNCqS1z^Ob zI}(+7X65~LJ;;{Y!eB5dq=YEqQST{D%g0Rxl~hVdW-Xruo9!mYdUi)7lt2YM;=&w% z2(I`?#iu~rVz9(sEfP-_Tl7T5=IplaBOrirQ!*rYZ0F=wuoyvfjsBvzwu`}!W8`Ve hEi$lZ`43!OD8S3cP|zey;1T?-B&RN0DQyz;e*nLwL5lzY literal 0 HcmV?d00001 diff --git a/packages/socket/test/socket_spec.js b/packages/socket/test/socket_spec.js index 74fd372f5855..1d030d6950e6 100644 --- a/packages/socket/test/socket_spec.js +++ b/packages/socket/test/socket_spec.js @@ -1,7 +1,6 @@ const fs = require('fs') const path = require('path') const server = require('socket.io') -const client = require('socket.io-client') const parser = require('socket.io-parser') const { hasBinary } = require('socket.io-parser/dist/is-binary') const expect = require('chai').expect @@ -18,21 +17,28 @@ describe('Socket', function () { }) it('exports client from lib/browser', function () { - expect(browserLib.client).to.eq(client) + expect(browserLib.client).to.be.defined }) it('exports createWebSocket from lib/browser', function () { expect(browserLib.createWebsocket).to.be.defined }) - it('creates a websocket for non webkit browsers', function () { - const socket = browserLib.createWebsocket({ path: '/path', browserFamily: 'chromium' }) + it('creates a websocket for non chromium and non webkit browsers', function () { + const socket = browserLib.createWebsocket({ path: '/path', browserFamily: 'firefox' }) expect(socket.io.opts.path).to.eq('/path') expect(socket.io.opts.transports[0]).to.eq('websocket') }) - it('creates a websocket for non webkit browsers', function () { + it('creates a websocket for chromium browsers', function () { + global.window = {} + const socket = browserLib.createWebsocket({ path: '/path', browserFamily: 'chromium' }) + + expect(socket._namespace).to.eq('/path/default') + }) + + it('creates a websocket for webkit browsers', function () { const socket = browserLib.createWebsocket({ path: '/path', browserFamily: 'webkit' }) expect(socket.io.opts.path).to.eq('/path') diff --git a/packages/socket/test/utils_spec.js b/packages/socket/test/utils_spec.js new file mode 100644 index 000000000000..63e5ce6ff2c5 --- /dev/null +++ b/packages/socket/test/utils_spec.js @@ -0,0 +1,48 @@ +const { encode, decode } = require('../lib/utils') +const expect = require('chai').expect +const { promises: fs } = require('fs') +const path = require('path') + +describe('utils', () => { + it('encodes and decodes a message with simple data', async () => { + const message = [{ type: 'test', data: { foo: 'bar' } }] + const encoded = await encode(message, '/namespace') + + // Ensure we can stringify and parse the result + const stringifiedEncoded = JSON.stringify(encoded) + const parsedEncoded = JSON.parse(stringifiedEncoded) + + const decoded = await decode(parsedEncoded) + + expect(decoded).to.deep.equal(message) + }) + + it('encodes and decodes a message with binary data', async () => { + const message = [{ file: await fs.readFile(path.join(__dirname, 'fixtures', 'cypress.png')) }] + const encoded = await encode(message, '/namespace') + + // Ensure we can stringify and parse the result + const stringifiedEncoded = JSON.stringify(encoded) + const parsedEncoded = JSON.parse(stringifiedEncoded) + + const decoded = await decode(parsedEncoded) + + expect(decoded).to.deep.equal(message) + }) + + it('encodes and decodes a message with circular data', async () => { + const inner = { foo: 'bar' } + + inner.self = inner + const message = [{ type: 'test', data: { inner } }] + const encoded = await encode(message, '/namespace') + + // Ensure we can stringify and parse the result + const stringifiedEncoded = JSON.stringify(encoded) + const parsedEncoded = JSON.parse(stringifiedEncoded) + + const decoded = await decode(parsedEncoded) + + expect(decoded).to.deep.equal(message) + }) +}) diff --git a/packages/types/src/protocol.ts b/packages/types/src/protocol.ts index 52cfa6512175..d3247c82d637 100644 --- a/packages/types/src/protocol.ts +++ b/packages/types/src/protocol.ts @@ -11,6 +11,7 @@ type Event = Events[T] export interface CDPClient { send> (command: T, params?: Command['paramsType'][0]): Promise['returnType']> on> (eventName: T, cb: (event: Event[0]) => void): void + off (eventName: string, cb: (event: any) => void): void } // TODO(protocol): This is basic for now but will evolve as we progress with the protocol work diff --git a/system-tests/__snapshots__/browser_crash_handling_spec.js b/system-tests/__snapshots__/browser_crash_handling_spec.js index 1d5d37d4cfbb..296a317f8436 100644 --- a/system-tests/__snapshots__/browser_crash_handling_spec.js +++ b/system-tests/__snapshots__/browser_crash_handling_spec.js @@ -18,7 +18,9 @@ exports['Browser Crash Handling / when the tab crashes in chrome / fails'] = ` -We detected that the Chromium Renderer process just crashed. +We detected that the Chrome Renderer process just crashed. + +We have failed the current spec but will continue running the next spec. This can happen for a number of different reasons. @@ -107,7 +109,9 @@ exports['Browser Crash Handling / when the tab crashes in electron / fails'] = ` -We detected that the Chromium Renderer process just crashed. +We detected that the Electron Renderer process just crashed. + +We have failed the current spec but will continue running the next spec. This can happen for a number of different reasons. @@ -198,7 +202,7 @@ exports['Browser Crash Handling / when the browser process crashes in chrome / f We detected that the Chrome process just crashed with code 'null' and signal 'SIGTRAP'. -We have failed the current test and have relaunched Chrome. +We have failed the current spec but will continue running the next spec. This can happen for many different reasons: @@ -262,28 +266,6 @@ This can happen for many different reasons: ✖ 1 of 2 failed (50%) XX:XX 1 1 1 - - -` - -exports['Browser Crash Handling / when the browser process crashes in electron / fails'] = ` - -==================================================================================================== - - (Run Starting) - - ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ Cypress: 1.2.3 │ - │ Browser: FooBrowser 88 │ - │ Specs: 2 found (chrome_process_crash.cy.js, simple.cy.js) │ - │ Searched: cypress/e2e/chrome_process_crash.cy.js, cypress/e2e/simple.cy.js │ - └────────────────────────────────────────────────────────────────────────────────────────────────┘ - - -──────────────────────────────────────────────────────────────────────────────────────────────────── - - Running: chrome_process_crash.cy.js (1 of 2) - - - ` exports['Browser Crash Handling / when the browser process crashes in chrome / fails w/ video on'] = ` @@ -308,7 +290,7 @@ exports['Browser Crash Handling / when the browser process crashes in chrome / f We detected that the Chrome process just crashed with code 'null' and signal 'SIGTRAP'. -We have failed the current test and have relaunched Chrome. +We have failed the current spec but will continue running the next spec. This can happen for many different reasons: @@ -465,3 +447,78 @@ exports['Browser Crash Handling / when the window closes mid launch of the brows ` + +exports['Browser Crash Handling / when the browser process is killed in chrome / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_process_kill.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_process_kill.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_process_kill.cy.js (1 of 2) + + +We detected that the Chrome browser process closed unexpectedly. + +We have failed the current spec and aborted the run. + +` + +exports['Browser Crash Handling / when the tab closes in chrome / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_tab_close.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_tab_close.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_tab_close.cy.js (1 of 2) + + +We detected that the Chrome tab running Cypress tests closed unexpectedly. + +We have failed the current spec and aborted the run. + +` + +exports['Browser Crash Handling / when the tab closes in electron / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_tab_close.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_tab_close.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_tab_close.cy.js (1 of 2) + + +We detected that the electron browser process closed unexpectedly. + +We have failed the current spec and aborted the run. + +` diff --git a/system-tests/lib/system-tests.ts b/system-tests/lib/system-tests.ts index 108867438fca..a0207737014f 100644 --- a/system-tests/lib/system-tests.ts +++ b/system-tests/lib/system-tests.ts @@ -632,7 +632,7 @@ const systemTests = { browser: process.env.SNAPSHOT_BROWSER || 'electron', headed: process.env.HEADED || false, project: 'e2e', - timeout: 120000, + timeout: Number(process.env.SYSTEM_TEST_TIMEOUT || 120000), originalTitle: null, expectedExitCode: 0, sanitizeScreenshotDimensions: false, diff --git a/system-tests/projects/e2e/cypress/e2e/chrome_process_kill.cy.js b/system-tests/projects/e2e/cypress/e2e/chrome_process_kill.cy.js new file mode 100644 index 000000000000..cbccb16990a7 --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/chrome_process_kill.cy.js @@ -0,0 +1,5 @@ +it('kills the chrome process', () => { + return Cypress.automation('remote:debugger:protocol', { + command: 'Browser.close', + }) +}) diff --git a/system-tests/projects/e2e/cypress/e2e/chrome_tab_close.cy.js b/system-tests/projects/e2e/cypress/e2e/chrome_tab_close.cy.js new file mode 100644 index 000000000000..e3fb35a08e44 --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/chrome_tab_close.cy.js @@ -0,0 +1,17 @@ +it('closes the chrome tab', () => { + return Cypress.automation('remote:debugger:protocol', { + command: 'Target.getTargets', + }) + .then(({ targetInfos = [] }) => { + const url = top.location.href + + const target = targetInfos.find((target) => target.url === url) + + return Cypress.automation('remote:debugger:protocol', { + command: 'Target.closeTarget', + params: { + targetId: target.targetId, + }, + }) + }) +}) diff --git a/system-tests/test/browser_crash_handling_spec.js b/system-tests/test/browser_crash_handling_spec.js index 865a2bad1b4d..9762dbb9f5bb 100644 --- a/system-tests/test/browser_crash_handling_spec.js +++ b/system-tests/test/browser_crash_handling_spec.js @@ -27,6 +27,67 @@ describe('Browser Crash Handling', () => { }) }) + // It should fail the chrome_tab_close spec, and exit early, do not move onto the next spec + context('when the tab closes in chrome', () => { + // const outputPath = path.join(e2ePath, 'output.json') + + systemTests.it('fails', { + browser: 'chrome', + spec: 'chrome_tab_close.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + // outputPath, + async onStdout (stdout) { + // TODO: we should be outputting valid json even + // if we early exit + // + // const json = await fs.readJsonAsync(outputPath) + // json.runs = systemTests.normalizeRuns(json.runs) + + // // also mutates into normalized obj ready for snapshot + // expectCorrectModuleApiResult(json, { + // e2ePath, runs: 4, video: false, + // }) + + const running = stdout.split('Running:').length - 1 + + expect(running).to.eq(1) + + expect(stdout).to.include('1 of 2') + expect(stdout).not.to.include('2 of 2') + }, + }) + }) + + // Because electron does not have any concepts with regard to a "page" aka a tab + // ...when the tab itself closes, the whole browser process is also closed + // so we actually want the same behavior as the "browser process is killed" + // and not to recover or continue, we should exit early + context('when the tab closes in electron', () => { + systemTests.it('fails', { + browser: 'electron', + spec: 'chrome_tab_close.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) + + // It should fail the chrome_process_kill spec, but the simple spec should run and succeed + // NOTE: we do NOT test the "browser process" being killed OR crashed in electron because + // there is no valid situation to simulate this. the main browser process is actually + // not the renderer process but the actual electron process, and killing it would be + // killing the entire cypress process, which is unrecoverable. this is also the same + // thing as hitting "CMD+C" in the terminal to kill the cypress process, so its not + // a situation we have to test for. + context('when the browser process is killed in chrome', () => { + systemTests.it('fails', { + browser: 'chrome', + spec: 'chrome_process_kill.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) + // It should fail the chrome_tab_crash spec, but the simple spec should run and succeed context('when the browser process crashes in chrome', () => { systemTests.it('fails w/ video off', { @@ -58,16 +119,6 @@ describe('Browser Crash Handling', () => { }) }) - // If chrome crashes, all of cypress crashes when in electron - context('when the browser process crashes in electron', () => { - systemTests.it('fails', { - browser: 'electron', - spec: 'chrome_process_crash.cy.js,simple.cy.js', - snapshot: true, - expectedExitCode: 1, - }) - }) - context('when the window closes mid launch of the browser process', () => { systemTests.it('passes', { browser: 'electron', diff --git a/system-tests/test/plugins_spec.js b/system-tests/test/plugins_spec.js index f7d52b0c129b..206e7275bb45 100644 --- a/system-tests/test/plugins_spec.js +++ b/system-tests/test/plugins_spec.js @@ -28,7 +28,7 @@ describe('e2e plugins', function () { it('fails when there is an async error inside an event handler', function () { // TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23493 - this.retries(15) + this.retries(30) return systemTests.exec(this, { spec: 'app.cy.js', diff --git a/system-tests/test/visit_spec.js b/system-tests/test/visit_spec.js index 278553bc4769..43cbcd1c2a48 100644 --- a/system-tests/test/visit_spec.js +++ b/system-tests/test/visit_spec.js @@ -138,7 +138,7 @@ describe('e2e visit', () => { return startTlsV1Server(6776) .then((serv) => { return exec() - .then(() => { + .finally(() => { return serv.destroy() }) }) diff --git a/yarn.lock b/yarn.lock index 306b6d2ba45b..c17f0a6553d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7422,6 +7422,11 @@ "@types/undertaker-registry" "*" async-done "~1.3.2" +"@types/uuid@8.3.2": + version "8.3.2" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.2.tgz#b7077bdfc866dbb39939029774806f24e95be791" + integrity sha512-u40ViizKDmdl5FhOXn9WQbulpigYCaiD5hD4KqR3xyQww6l3+0ND+A9TeFla8tFpqvR+UAkJdYb/8jdaQG4/nw== + "@types/verror@^1.10.3": version "1.10.5" resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.5.tgz#2a1413aded46e67a1fe2386800e291123ed75eb1" @@ -12894,11 +12899,6 @@ detective@^5.0.2: defined "^1.0.0" minimist "^1.1.1" -devtools-protocol@0.0.1124027: - version "0.0.1124027" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1124027.tgz#a5bd4af77a8a2d84c035d178ee01a4c47cdc2d45" - integrity sha512-OT2sdgQn4llM9/tVcCvoty733KFFIlXVvESceJsfazhmg/dF7C5e3Z8cIN8jNwIikixuE5rufGtD1cvKHXfOcQ== - devtools-protocol@0.0.927104: version "0.0.927104" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.927104.tgz#3bba0fca644bcdce1bcebb10ae392ab13428a7a0"