diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 87ef9285d4d5..06821745825a 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 11/19/2024 (PENDING)_ **Features:** - Updated the protocol to be able to flex logic based on project config. Addresses [#30560](https://github.com/cypress-io/cypress/issues/30560). +- Added new [`defaultBrowser`](https://docs.cypress.io/app/references/configuration#Browser) configuration option to specify the default browser to launch. This option only affects the first browser launch; changing this option after the browser is already launched will have no effect. Addresses [#6646](https://github.com/cypress-io/cypress/issues/6646). **Bugfixes:** diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index f0f0c914b1c3..ab1dba589fe2 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -3216,6 +3216,11 @@ declare namespace Cypress { setupNodeEvents: (on: PluginEvents, config: PluginConfigOptions) => Promise | PluginConfigOptions | void indexHtmlFile: string + + /** + * The default browser to launch if the "--browser" command line option is not provided. + */ + defaultBrowser: string } interface EndToEndConfigOptions extends Omit { diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index c366c3230ee0..d119255c38cc 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -26,6 +26,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1 'specPattern': '**/*.cy.{js,jsx,ts,tsx}', 'indexHtmlFile': 'cypress/support/component-index.html', }, + 'defaultBrowser': null, 'defaultCommandTimeout': 4000, 'downloadsFolder': 'cypress/downloads', 'e2e': { @@ -117,6 +118,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f 'specPattern': '**/*.cy.{js,jsx,ts,tsx}', 'indexHtmlFile': 'cypress/support/component-index.html', }, + 'defaultBrowser': null, 'defaultCommandTimeout': 4000, 'downloadsFolder': 'cypress/downloads', 'e2e': { @@ -206,6 +208,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key 'chromeWebSecurity', 'clientCertificates', 'component', + 'defaultBrowser', 'defaultCommandTimeout', 'downloadsFolder', 'e2e', diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 87bfa61496d0..fd0afbc5e5eb 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -169,6 +169,10 @@ const driverConfigOptions: Array = [ indexHtmlFile: 'cypress/support/component-index.html', }, validation: isValidConfig, + }, { + name: 'defaultBrowser', + defaultValue: null, + validation: validate.isString, }, { name: 'defaultCommandTimeout', defaultValue: 4000, diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 0a3ee14c76b8..a958047b54e8 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1054,6 +1054,7 @@ describe('config/src/project/utils', () => { browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, clientCertificates: { value: [], from: 'default' }, + defaultBrowser: { value: null, from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, downloadsFolder: { value: 'cypress/downloads', from: 'default' }, env: {}, @@ -1152,6 +1153,7 @@ describe('config/src/project/utils', () => { browsers: { value: [], from: 'default' }, chromeWebSecurity: { value: true, from: 'default' }, clientCertificates: { value: [], from: 'default' }, + defaultBrowser: { value: null, from: 'default' }, defaultCommandTimeout: { value: 4000, from: 'default' }, downloadsFolder: { value: 'cypress/downloads', from: 'default' }, execTimeout: { value: 60000, from: 'default' }, diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index 895dc4d12e4e..7f614bd67827 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -83,7 +83,7 @@ export interface GraphQLRequestInfo { export class DataContext { readonly graphqlRequestInfo?: GraphQLRequestInfo private _config: Omit - private _modeOptions: Readonly> + private _modeOptions: Partial private _coreData: CoreDataShape readonly lifecycleManager: ProjectLifecycleManager @@ -122,7 +122,7 @@ export class DataContext { return new RemoteRequestDataSource() } - get modeOptions () { + get modeOptions (): Readonly> { return this._modeOptions } @@ -425,4 +425,8 @@ export class DataContext { this.#awaitingEmptyRequestCount.push(resolve) }) } + + updateModeOptionsBrowser (browser: string) { + this._modeOptions.browser = browser + } } diff --git a/packages/data-context/src/actions/BrowserActions.ts b/packages/data-context/src/actions/BrowserActions.ts index a78dd045cff9..35177f563a4f 100644 --- a/packages/data-context/src/actions/BrowserActions.ts +++ b/packages/data-context/src/actions/BrowserActions.ts @@ -40,6 +40,11 @@ export class BrowserActions { }) } + setCliBrowser (browser: string) { + this.ctx.updateModeOptionsBrowser(browser) + this.ctx.coreData.cliBrowser = browser + } + async focusActiveBrowserWindow () { await this.browserApi.focusActiveBrowserWindow() } diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index c46d10b5161d..16fc41f79a90 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -308,10 +308,25 @@ export class ProjectLifecycleManager { /** * Sets the initial `activeBrowser` depending on these criteria, in order of preference: * 1. The value of `--browser` passed via CLI. - * 2. The last browser selected in `open` mode (by name and channel) for this project. - * 3. The first browser found. + * 2. The value of `defaultBrowser` in `cypress.config`. + * 3. The last browser selected in `open` mode (by name and channel) for this project. + * 4. The first browser found. */ async setInitialActiveBrowser () { + const configDefaultBrowser = this.loadedFullConfig?.defaultBrowser + + // if we have a default browser from the config and a CLI browser wasn't passed and the active browser hasn't been set + // set the cliBrowser to the defaultBrowser from the config since we want the defaultBrowser to behave as if it was passed via CLI + if (configDefaultBrowser && !this.ctx.modeOptions.isBrowserGivenByCli && !this.ctx.coreData.activeBrowser) { + this.ctx.actions.browser.setCliBrowser(configDefaultBrowser) + } + + // if we already have an activeBrowser, that means we are reloading the browser (e.g. after a config change in open mode) + // so we need to set the CLI browser to the activeBrowser to ensure the GUI shows the correct browser + if (this.ctx.coreData.activeBrowser && !process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { + this.ctx.actions.browser.setCliBrowser(`${this.ctx.coreData.activeBrowser.name}:${this.ctx.coreData.activeBrowser.channel}`) + } + if (this.ctx.coreData.cliBrowser) { await this.setActiveBrowserByNameOrPath(this.ctx.coreData.cliBrowser) diff --git a/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts b/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts index b526cc529194..07fcd4e5965c 100644 --- a/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts +++ b/packages/data-context/test/unit/data/ProjectLifecycleManager.spec.ts @@ -2,12 +2,13 @@ import { expect } from 'chai' import type { DataContext } from '../../../src' import { createTestDataContext } from '../helper' import sinon from 'sinon' -import { FullConfig } from '@packages/types' +import { FoundBrowser, FullConfig } from '@packages/types' const browsers = [ { name: 'electron', family: 'chromium', channel: 'stable', displayName: 'Electron' }, { name: 'chrome', family: 'chromium', channel: 'stable', displayName: 'Chrome' }, { name: 'chrome', family: 'chromium', channel: 'beta', displayName: 'Chrome Beta' }, + { name: 'firefox', family: 'firefox', channel: 'stable', displayName: 'Firefox' }, ] let ctx: DataContext @@ -15,9 +16,6 @@ let ctx: DataContext function createDataContext (modeOptions?: Parameters[1]) { const context = createTestDataContext('open', modeOptions) - context.coreData.activeBrowser = undefined - context.coreData.cliBrowser = undefined - context._apis.browserApi.getBrowsers = sinon.stub().resolves(browsers) context._apis.projectApi.insertProjectPreferencesToCache = sinon.stub() context.actions.project.launchProject = sinon.stub().resolves() @@ -42,6 +40,9 @@ describe('ProjectLifecycleManager', () => { context('#setInitialActiveBrowser', () => { it('falls back to browsers[0] if preferences and cliBrowser do not exist', async () => { + ctx.coreData.activeBrowser = null + ctx.coreData.cliBrowser = null + await ctx.lifecycleManager.setInitialActiveBrowser() expect(ctx.coreData.activeBrowser).to.include({ name: 'electron' }) @@ -51,6 +52,7 @@ describe('ProjectLifecycleManager', () => { it('uses cli --browser option if one is set', async () => { ctx._apis.browserApi.ensureAndGetByNameOrPath = sinon.stub().withArgs('electron').resolves(browsers[0]) + ctx.coreData.activeBrowser = null ctx.coreData.cliBrowser = 'electron' await ctx.lifecycleManager.setInitialActiveBrowser() @@ -68,6 +70,7 @@ describe('ProjectLifecycleManager', () => { ctx._apis.browserApi.ensureAndGetByNameOrPath = sinon.stub().withArgs('electron').resolves(browsers[0]) + ctx.coreData.activeBrowser = null ctx.coreData.cliBrowser = 'electron' await ctx.lifecycleManager.setInitialActiveBrowser() @@ -79,6 +82,8 @@ describe('ProjectLifecycleManager', () => { it('uses lastBrowser if available', async () => { ctx.project.getProjectPreferences = sinon.stub().resolves({ lastBrowser: { name: 'chrome', channel: 'beta' } }) + ctx.coreData.activeBrowser = null + ctx.coreData.cliBrowser = null await ctx.lifecycleManager.setInitialActiveBrowser() @@ -88,12 +93,84 @@ describe('ProjectLifecycleManager', () => { it('falls back to browsers[0] if lastBrowser does not exist', async () => { ctx.project.getProjectPreferences = sinon.stub().resolves({ lastBrowser: { name: 'chrome', channel: 'dev' } }) + ctx.coreData.activeBrowser = null + ctx.coreData.cliBrowser = null await ctx.lifecycleManager.setInitialActiveBrowser() expect(ctx.coreData.activeBrowser).to.include({ name: 'electron' }) expect(ctx.actions.project.launchProject).to.not.be.called }) + + it('uses config defaultBrowser option if --browser is not given', async () => { + ctx = createDataContext({ + project: 'foo', + testingType: 'e2e', + isBrowserGivenByCli: false, + }) + + ctx._apis.browserApi.ensureAndGetByNameOrPath = sinon.stub().withArgs('chrome').resolves(browsers[1]) + sinon.stub(ctx.lifecycleManager, 'loadedFullConfig').get(() => ({ defaultBrowser: 'chrome' })) + + expect(ctx.modeOptions.browser).to.eq(undefined) + expect(ctx.coreData.cliBrowser).to.eq(null) + expect(ctx.coreData.activeBrowser).to.eq(null) + + await ctx.lifecycleManager.setInitialActiveBrowser() + + expect(ctx.modeOptions.browser).to.eq('chrome') + expect(ctx.coreData.cliBrowser).to.eq('chrome') + expect(ctx.coreData.activeBrowser).to.eq(browsers[1]) + }) + + it('doesn\'t use config defaultBrowser option if --browser is given', async () => { + ctx = createDataContext({ + project: 'foo', + testingType: 'e2e', + browser: 'firefox', + isBrowserGivenByCli: true, + }) + + sinon.stub(ctx.lifecycleManager, 'getFullInitialConfig').resolves(fullConfig) + ctx._apis.browserApi.ensureAndGetByNameOrPath = sinon.stub().withArgs('firefox').resolves(browsers[3]) + sinon.stub(ctx.lifecycleManager, 'loadedFullConfig').get(() => ({ defaultBrowser: 'chrome' })) + + expect(ctx.modeOptions.browser).to.eq('firefox') + expect(ctx.coreData.cliBrowser).to.eq('firefox') + expect(ctx.coreData.activeBrowser).to.eq(null) + + await ctx.lifecycleManager.setInitialActiveBrowser() + + expect(ctx.modeOptions.browser).to.eq('firefox') + expect(ctx.coreData.cliBrowser).to.eq('firefox') + expect(ctx.coreData.activeBrowser).to.eq(browsers[3]) + }) + + it('ignores the defaultBrowser if there is an active browser and updates the CLI browser to the active browser', async () => { + ctx = createDataContext({ + project: 'foo', + testingType: 'e2e', + isBrowserGivenByCli: false, + }) + + sinon.stub(ctx.lifecycleManager, 'getFullInitialConfig').resolves(fullConfig) + ctx._apis.browserApi.ensureAndGetByNameOrPath = sinon.stub().withArgs('chrome:beta').resolves(browsers[2]) + // the default browser will be ignored since we have an active browser + sinon.stub(ctx.lifecycleManager, 'loadedFullConfig').get(() => ({ defaultBrowser: 'firefox' })) + + // set the active browser to chrome:beta + ctx.actions.browser.setActiveBrowser(browsers[2] as FoundBrowser) + + expect(ctx.modeOptions.browser).to.eq(undefined) + expect(ctx.coreData.cliBrowser).to.eq(null) + expect(ctx.coreData.activeBrowser).to.eq(browsers[2]) + + await ctx.lifecycleManager.setInitialActiveBrowser() + + expect(ctx.modeOptions.browser).to.eq('chrome:beta') + expect(ctx.coreData.cliBrowser).to.eq('chrome:beta') + expect(ctx.coreData.activeBrowser).to.eq(browsers[2]) + }) }) context('#eventProcessPid', () => { diff --git a/packages/server/lib/modes/index.ts b/packages/server/lib/modes/index.ts index c542b7d807b9..99b7085e8e5f 100644 --- a/packages/server/lib/modes/index.ts +++ b/packages/server/lib/modes/index.ts @@ -10,6 +10,8 @@ export = (mode, options) => { return require('./smoke_test').run(options) } + options.isBrowserGivenByCli = options.browser !== undefined + if (mode === 'run') { _.defaults(options, { socketId: random.id(10), diff --git a/packages/types/src/modeOptions.ts b/packages/types/src/modeOptions.ts index f8ab17f81ec2..f12a99399f24 100644 --- a/packages/types/src/modeOptions.ts +++ b/packages/types/src/modeOptions.ts @@ -24,6 +24,7 @@ export interface RunModeOptions extends CommonModeOptions { parallel?: boolean | null ciBuildId?: string | null tag?: (string)[] | null + isBrowserGivenByCli: boolean } export type TestingType = 'e2e' | 'component' diff --git a/system-tests/__snapshots__/results_spec.ts.js b/system-tests/__snapshots__/results_spec.ts.js index bcc4f872c47e..a81d683240e7 100644 --- a/system-tests/__snapshots__/results_spec.ts.js +++ b/system-tests/__snapshots__/results_spec.ts.js @@ -16,6 +16,7 @@ exports['module api and after:run results'] = ` "blockHosts": null, "chromeWebSecurity": true, "clientCertificates": [], + "defaultBrowser": null, "defaultCommandTimeout": 4000, "downloadsFolder": "/path/to/downloadsFolder", "env": {}, diff --git a/system-tests/projects/config-defaultBrowser/cypress.config.js b/system-tests/projects/config-defaultBrowser/cypress.config.js new file mode 100644 index 000000000000..4276ccd434fc --- /dev/null +++ b/system-tests/projects/config-defaultBrowser/cypress.config.js @@ -0,0 +1,6 @@ +module.exports = { + e2e: { + supportFile: false, + }, + defaultBrowser: 'chrome', +} diff --git a/system-tests/projects/config-defaultBrowser/cypress/e2e/spec.cy.js b/system-tests/projects/config-defaultBrowser/cypress/e2e/spec.cy.js new file mode 100644 index 000000000000..434d054dfaa1 --- /dev/null +++ b/system-tests/projects/config-defaultBrowser/cypress/e2e/spec.cy.js @@ -0,0 +1,3 @@ +it('works', () => { + expect(1).to.eq(1) +}) diff --git a/system-tests/test/config_spec.js b/system-tests/test/config_spec.js index b71c4a206a37..4668c329c8d4 100644 --- a/system-tests/test/config_spec.js +++ b/system-tests/test/config_spec.js @@ -243,4 +243,17 @@ describe('e2e config', () => { snapshot: true, }) }) + + it('launches browser using config.defaultBrowser', async function () { + await Fixtures.scaffoldProject('config-defaultBrowser') + + return systemTests.exec(this, { + project: 'config-defaultBrowser', + command: 'cypress', + args: ['run', '--dev', '--project', path.resolve(process.cwd(), './projects/config-defaultBrowser')], + onStdout: (stdout) => { + expect(stdout).to.include('Browser: Chrome') + }, + }) + }) })