diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 734f96fa730..933106dd272 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -9,8 +9,9 @@ _Released 10/20/2025 (PENDING)_ **Bugfixes:** -- Fixed an issue where grouped command text jumps up and down when expanding and collapsing in the command log. Addressed in [#32757](https://github.com/cypress-io/cypress/pull/32757). - Fixed an issue where command snapshots were not correctly displayed in Studio. Addressed in [#32808](https://github.com/cypress-io/cypress/pull/32808). +- Chrome's autofill popup is now disabled when filling address and credit card forms during test execution. We also added some other Chrome flags and preferences that are common when automating browsers. Fixes [#25608](https://github.com/cypress-io/cypress/issues/25608). Addressed in [#32811](https://github.com/cypress-io/cypress/pull/32811). +- Fixed an issue where grouped command text jumps up and down when expanding and collapsing in the command log. Addressed in [#32757](https://github.com/cypress-io/cypress/pull/32757). - Fixed an issue with grouped console prop items having a hard to read blue color in the console log and duplicate `:` characters being displayed. Addressed in [#32776](https://github.com/cypress-io/cypress/pull/32776). - Added more context to the error message shown when `cy.prompt()` fails to download. Addressed in [#32822](https://github.com/cypress-io/cypress/pull/32822). diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index a0d3e1ed372..fcecf12596f 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -49,6 +49,29 @@ const pathToTheme = extension.getPathToTheme() let browserCriClient: BrowserCriClient | undefined +// Generates default Chrome preferences that Cypress applies to all Chrome instances +const _getDefaultChromePreferences = (): ChromePreferences => { + return { + default: { + autofill: { + profile_enabled: false, // Disable Chrome's "Save address" pop up + credit_card_enabled: false, // Disable Chrome's "Save card" pop up + }, + }, + defaultSecure: {}, + localState: { + browser: { + // Hide security warnings when potentially dangerous command-line flags are used. + command_line_flag_security_warnings_enabled: false, + // Setting the policy controls the presentation of promotional content, + // including the welcome pages that help users sign in to Google Chrome, set + // Google Chrome as users' default browser, or otherwise inform them of product features. + promotions_enabled: false, + }, + }, + } +} + /** * Reads all known preference files (CHROME_PREFERENCE_PATHS) from disk and return * @param userDir @@ -78,6 +101,16 @@ const _getChromePreferences = (userDir: string): Bluebird => })) } +// Reads raw preferences from disk and merges them with defaults +const _getChromePreferencesWithDefaults = async (userDir: string): Promise => { + const existingPrefs = await _getChromePreferences(userDir) + + // Merge default preferences with existing preferences + const defaultPrefs = module.exports._getDefaultChromePreferences() + + return _mergeChromePreferences(defaultPrefs, existingPrefs) +} + const _mergeChromePreferences = (originalPrefs: ChromePreferences, newPrefs: ChromePreferences): ChromePreferences => { return _.mapValues(CHROME_PREFERENCE_PATHS, (_v, prefPath) => { const original = _.cloneDeep(originalPrefs[prefPath]) @@ -118,10 +151,16 @@ const _writeChromePreferences = (userDir: string, originalPrefs: ChromePreferenc const newJson = newPrefs[key] if (!newJson || _.isEqual(originalJson, newJson)) { + debug('skipping writing preferences for %s: no changes detected', key) + return } - return fs.outputJson(path.join(userDir, CHROME_PREFERENCE_PATHS[key]), newJson) + const prefPath = path.join(userDir, CHROME_PREFERENCE_PATHS[key]) + + debug('writing Chrome preferences to %s: %o', prefPath, newJson) + + return fs.outputJson(prefPath, newJson) }) .return() } @@ -296,6 +335,10 @@ export = { _getChromePreferences, + _getChromePreferencesWithDefaults, + + _getDefaultChromePreferences, + _mergeChromePreferences, _writeChromePreferences, @@ -535,7 +578,7 @@ export = { const userDir = utils.getProfileDir(browser, isTextTerminal) - const [port, preferences] = await Bluebird.all([ + const [port, rawPreferences] = await Bluebird.all([ protocol.getRemoteDebuggingPort(), _getChromePreferences(userDir), ]) @@ -543,7 +586,7 @@ export = { const defaultArgs = this._getArgs(browser, options, port) const defaultLaunchOptions = utils.getDefaultLaunchOptions({ - preferences, + preferences: rawPreferences, args: defaultArgs, }) @@ -554,10 +597,16 @@ export = { utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options), ]) + // Merge preferences BEFORE writing them to disk + // Start with defaults merged with raw preferences + let finalPreferences = _mergeChromePreferences(module.exports._getDefaultChromePreferences(), rawPreferences) + if (launchOptions.preferences) { - launchOptions.preferences = _mergeChromePreferences(preferences, launchOptions.preferences as ChromePreferences) + finalPreferences = _mergeChromePreferences(finalPreferences, launchOptions.preferences as ChromePreferences) } + debug('final Chrome preferences to be written: %o', finalPreferences) + const [extDest] = await Bluebird.all([ this._writeExtension( browser, @@ -567,7 +616,8 @@ export = { _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), + // Write the final merged preferences BEFORE launching the browser + _writeChromePreferences(userDir, rawPreferences, finalPreferences), ]) // normalize the --load-extensions argument by // massaging what the user passed into our own diff --git a/packages/server/lib/util/chromium_flags.ts b/packages/server/lib/util/chromium_flags.ts index 3faa4171f4b..e763e4a3781 100644 --- a/packages/server/lib/util/chromium_flags.ts +++ b/packages/server/lib/util/chromium_flags.ts @@ -1,12 +1,34 @@ const disabledFeatures = [ - // Disable manual option and popup prompt of Chrome translation - // https://github.com/cypress-io/cypress/issues/28225 - 'Translate', + // Uncomment to force the deprecation of unload events + // 'DeprecateUnloadByUserAndOrigin', + + // Hide toolbar button that opens dialog for controlling media sessions. + 'GlobalMediaControls', + + // Disables the Interest Feed Content Suggestions, + // which is a feature that shows content suggestions based on the user's interests. + // https://www.google.com/interests/saved + 'InterestFeedContentSuggestions', + + // Hides the Lens feature in the URL address bar. + 'LensOverlay', + + // Avoid the startup dialog for _Do you want the application 'Chromium.app' to accept incoming network connections?_. + // Also disables the Chrome Media Router https://chromium.googlesource.com/chromium/src/+/HEAD/docs/media/media_router.md + // which creates background networking activity to discover cast targets. A superset of disabling `DialMediaRouteProvider`. + 'MediaRouter', + + // Disable the Chrome Optimization Guide https://chromium.googlesource.com/chromium/src/+/HEAD/components/optimization_guide/) + // and networking with its service API + 'OptimizationHints', + // Disables "Enhanced ad privacy in Chrome" dialog // https://github.com/cypress-io/cypress/issues/29199 'PrivacySandboxSettings4', - // Uncomment to force the deprecation of unload events - // 'DeprecateUnloadByUserAndOrigin', + + // Disable manual option and popup prompt of Chrome translation + // https://github.com/cypress-io/cypress/issues/28225 + 'Translate', ] // Common Chrome Flags for Automation @@ -20,6 +42,11 @@ const DEFAULT_FLAGS = [ 'no-first-run', 'noerrdialogs', 'enable-fixed-layout', + // Disables Domain Reliability Monitoring, which tracks whether the browser has + // difficulty contacting Google-owned sites and uploads reports to Google. + 'disable-domain-reliability', + // Disable field trial tests configured in fieldtrial_testing_config.json. + 'disable-field-trial-config', 'disable-popup-blocking', 'disable-password-generation', 'disable-single-click-autofill', diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 78a8d7a1d26..cb4a6ba19f9 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -10,12 +10,33 @@ const utils = require(`../../../lib/browsers/utils`) const chrome = require(`../../../lib/browsers/chrome`) const { fs } = require(`../../../lib/util/fs`) const { BrowserCriClient } = require('../../../lib/browsers/browser-cri-client') +const protocol = require(`../../../lib/browsers/protocol`) const { expect } = require('chai') const openOpts = { onError: () => {}, } +// Helper function to create consistent mock preferences for testing +const createMockDefaultPreferences = () => ({ + default: { + fake_preference: { + value: 'value', + }, + }, + defaultSecure: {}, + localState: { + fake_local_state: { + value: 'value', + }, + }, +}) + +// Helper function to mock _getDefaultChromePreferences with consistent fake preferences +const mockGetDefaultChromePreferences = () => { + return sinon.stub(chrome, '_getDefaultChromePreferences').returns(createMockDefaultPreferences()) +} + describe('lib/browsers/chrome', () => { context('#open', () => { beforeEach(function () { @@ -955,5 +976,417 @@ describe('lib/browsers/chrome', () => { expect(statePrefs).to.not.be.called }) }) + + it('writes default preferences when they do not exist on disk', async () => { + const outputJson = sinon.stub(fs, 'outputJson') + const defaultPrefs = outputJson.withArgs('/foo/Default/Preferences').resolves() + const securePrefs = outputJson.withArgs('/foo/Default/Secure Preferences').resolves() + const statePrefs = outputJson.withArgs('/foo/Local State').resolves() + + // Mock _getDefaultChromePreferences to return fake preferences for testing + const mockDefaultPrefs = mockGetDefaultChromePreferences() + + // Simulate empty preferences read from disk (no defaults exist yet) + const originalPrefs = { + default: {}, + defaultSecure: {}, + localState: {}, + } + + // Get the default preferences that should be written + const defaultChromePrefs = chrome._getDefaultChromePreferences() + + await chrome._writeChromePreferences('/foo', originalPrefs, defaultChromePrefs) + + // Should write default preferences since they don't exist on disk + expect(defaultPrefs).to.be.calledWith('/foo/Default/Preferences', { + fake_preference: { + value: 'value', + }, + }) + + // defaultSecure is empty, so it should not be written + expect(securePrefs).to.not.be.called + + expect(statePrefs).to.be.calledWith('/foo/Local State', { + fake_local_state: { + value: 'value', + }, + }) + + mockDefaultPrefs.restore() + }) + }) + + context('#_getDefaultChromePreferences', () => { + it('returns expected default preferences', () => { + const defaultPrefs = chrome._getDefaultChromePreferences() + + expect(defaultPrefs).to.be.an('object') + expect(defaultPrefs).to.have.property('default') + expect(defaultPrefs).to.have.property('defaultSecure') + expect(defaultPrefs).to.have.property('localState') + }) + }) + + context('#_getChromePreferencesWithDefaults', () => { + beforeEach(() => { + sinon.stub(fs, 'readJson') + }) + + afterEach(() => { + fs.readJson.restore() + }) + + it('merges defaults with existing preferences', () => { + const mockDefaults = createMockDefaultPreferences() + const mockDefaultPrefs = sinon.stub(chrome, '_getDefaultChromePreferences').returns(mockDefaults) + + fs.readJson.withArgs('/foo/Default/Preferences').resolves({ existing: 'value' }) + fs.readJson.withArgs('/foo/Default/Secure Preferences').resolves({ secure: 'value' }) + fs.readJson.withArgs('/foo/Local State').resolves({ local: 'value' }) + + return chrome._getChromePreferencesWithDefaults('/foo') + .then((result) => { + // Should merge defaults with existing preferences, where existing values take precedence + expect(result).to.deep.eq({ + default: { + fake_preference: { + value: 'value', + }, + existing: 'value', // existing preference should be merged in + }, + defaultSecure: { + secure: 'value', // existing preference should be merged in + }, + localState: { + fake_local_state: { + value: 'value', + }, + local: 'value', // existing preference should be merged in + }, + }) + }) + .finally(() => { + mockDefaultPrefs.restore() + }) + }) + + it('returns defaults when no existing preferences', () => { + const mockDefaults = createMockDefaultPreferences() + const mockDefaultPrefs = sinon.stub(chrome, '_getDefaultChromePreferences').returns(mockDefaults) + + fs.readJson.withArgs('/foo/Default/Preferences').rejects({ code: 'ENOENT' }) + fs.readJson.withArgs('/foo/Default/Secure Preferences').rejects({ code: 'ENOENT' }) + fs.readJson.withArgs('/foo/Local State').rejects({ code: 'ENOENT' }) + + return chrome._getChromePreferencesWithDefaults('/foo') + .then((result) => { + expect(result).to.deep.eq(mockDefaults) + }) + .finally(() => { + mockDefaultPrefs.restore() + }) + }) + }) + + context('#_getChromePreferences with IGNORE_CHROME_PREFERENCES', () => { + beforeEach(() => { + process.env.IGNORE_CHROME_PREFERENCES = 'true' + }) + + afterEach(() => { + delete process.env.IGNORE_CHROME_PREFERENCES + }) + + it('returns empty preferences when IGNORE_CHROME_PREFERENCES is set', () => { + return chrome._getChromePreferences('/foo') + .then((result) => { + expect(result).to.deep.eq({ + default: {}, + defaultSecure: {}, + localState: {}, + }) + }) + }) + }) + + context('#_writeChromePreferences with IGNORE_CHROME_PREFERENCES', () => { + beforeEach(() => { + process.env.IGNORE_CHROME_PREFERENCES = 'true' + }) + + afterEach(() => { + delete process.env.IGNORE_CHROME_PREFERENCES + }) + + it('does not write preferences when IGNORE_CHROME_PREFERENCES is set', () => { + const outputJson = sinon.stub(fs, 'outputJson') + + const originalPrefs = { default: {}, defaultSecure: {}, localState: {} } + const newPrefs = { default: { test: 'value' }, defaultSecure: {}, localState: {} } + + return chrome._writeChromePreferences('/foo', originalPrefs, newPrefs) + .then(() => { + expect(outputJson).to.not.be.called + }) + }) + }) + + context('#_mergeChromePreferences with user preferences', () => { + it('merges user preferences with defaults correctly', () => { + // Mock _getDefaultChromePreferences to return fake preferences for testing + const mockDefaultPrefs = mockGetDefaultChromePreferences() + + const defaultPrefs = chrome._getDefaultChromePreferences() + const userPrefs = { + default: { + fake_preference: { + value: 'value', + }, + newSetting: 'userValue', // User adds new setting + }, + defaultSecure: { + fake_secure_preference: { + value: 'value', + }, + }, + localState: { + fake_local_state: { + value: 'value', + }, + newLocalSetting: 'userValue', // User adds new setting + }, + } + + const result = chrome._mergeChromePreferences(defaultPrefs, userPrefs) + + expect(result.default).to.deep.eq({ + fake_preference: { + value: 'value', + }, + newSetting: 'userValue', // User addition + }) + + expect(result.localState).to.deep.eq({ + fake_local_state: { + value: 'value', + }, + newLocalSetting: 'userValue', // User addition + }) + + mockDefaultPrefs.restore() + }) + + it('handles preference deletion with null values', () => { + const originalPrefs = { + default: { + keepThis: 'value', + deleteThis: 'value', + }, + defaultSecure: { + keepThis: 'value', + deleteThis: 'value', + }, + localState: { + keepThis: 'value', + deleteThis: 'value', + }, + } + + const newPrefs = { + default: { + deleteThis: null, // Should be deleted + addThis: 'newValue', + }, + defaultSecure: { + deleteThis: null, // Should be deleted + }, + localState: { + deleteThis: null, // Should be deleted + addThis: 'newValue', + }, + } + + const result = chrome._mergeChromePreferences(originalPrefs, newPrefs) + + expect(result.default).to.deep.eq({ + keepThis: 'value', + addThis: 'newValue', + }) + + expect(result.defaultSecure).to.deep.eq({ + keepThis: 'value', + }) + + expect(result.localState).to.deep.eq({ + keepThis: 'value', + addThis: 'newValue', + }) + }) + }) + + context('#_getChromePreferences error handling', () => { + beforeEach(() => { + sinon.stub(fs, 'readJson') + }) + + afterEach(() => { + fs.readJson.restore() + }) + + it('handles corrupted preference files gracefully', () => { + fs.readJson.withArgs('/foo/Default/Preferences').rejects({ code: 'ENOENT' }) + fs.readJson.withArgs('/foo/Default/Secure Preferences').rejects(new Error('Invalid JSON')) + fs.readJson.withArgs('/foo/Local State').resolves({ valid: 'data' }) + + return chrome._getChromePreferences('/foo') + .then(() => { + expect.fail('Should have thrown an error for corrupted file') + }) + .catch((err) => { + expect(err.message).to.include('Invalid JSON') + }) + }) + + it('handles missing files gracefully', () => { + fs.readJson.withArgs('/foo/Default/Preferences').rejects({ code: 'ENOENT' }) + fs.readJson.withArgs('/foo/Default/Secure Preferences').rejects({ code: 'ENOENT' }) + fs.readJson.withArgs('/foo/Local State').rejects({ code: 'ENOENT' }) + + return chrome._getChromePreferences('/foo') + .then((result) => { + expect(result).to.deep.eq({ + default: {}, + defaultSecure: {}, + localState: {}, + }) + }) + }) + }) + + context('#open integration with preferences', () => { + beforeEach(function () { + // Mock all the dependencies + this.pageCriClient = { + send: sinon.stub().resolves(), + Page: { screencastFrame: sinon.stub().returns() }, + close: sinon.stub().resolves(), + on: sinon.stub(), + } + + this.browserCriClient = { + attachToTargetUrl: sinon.stub().resolves(this.pageCriClient), + close: sinon.stub().resolves(), + getWebSocketDebuggerUrl: sinon.stub().returns('ws://debugger'), + resetBrowserTargets: sinon.stub().resolves(), + } + + this.automation = { + push: sinon.stub(), + use: sinon.stub().returns(), + onServiceWorkerClientEvent: sinon.stub(), + } + + this.launchedBrowser = { + kill: sinon.stub().returns(), + } + + sinon.stub(chrome, '_writeExtension').resolves('/path/to/ext') + sinon.stub(BrowserCriClient, 'create').resolves(this.browserCriClient) + sinon.stub(utils, 'getProfileDir').returns('/profile/dir') + sinon.stub(utils, 'ensureCleanCache').resolves('/profile/dir/CypressCache') + sinon.stub(utils, 'initializeCDP').resolves() + sinon.stub(utils, 'getDefaultLaunchOptions').returns({ args: [], preferences: null }) + sinon.stub(utils, 'executeBeforeBrowserLaunch').resolves({ args: [], preferences: null }) + sinon.stub(utils, 'executeAfterBrowserLaunch').resolves() + sinon.stub(protocol, 'getRemoteDebuggingPort').resolves(50505) + sinon.stub(launch, 'launch').resolves(this.launchedBrowser) + + // Mock _getDefaultChromePreferences to return fake preferences for testing + this.mockDefaultPrefs = mockGetDefaultChromePreferences() + + this.readJson = sinon.stub(fs, 'readJson') + this.readJson.withArgs('/profile/dir/Default/Preferences').rejects({ code: 'ENOENT' }) + this.readJson.withArgs('/profile/dir/Default/Secure Preferences').rejects({ code: 'ENOENT' }) + this.readJson.withArgs('/profile/dir/Local State').rejects({ code: 'ENOENT' }) + + this.outputJson = sinon.stub(fs, 'outputJson') + this.outputJson.resolves() + }) + + afterEach(function () { + launch.launch.restore() + protocol.getRemoteDebuggingPort.restore() + fs.readJson.restore() + fs.outputJson.restore() + this.mockDefaultPrefs.restore() + }) + + it('writes default preferences during browser launch', async function () { + await chrome.open({ isHeadless: true }, 'http://localhost:3000', openOpts, this.automation) + + // Verify that default preferences were written + expect(this.outputJson).to.have.been.calledWith( + '/profile/dir/Default/Preferences', + sinon.match({ + fake_preference: { + value: 'value', + }, + }), + ) + + expect(this.outputJson).to.have.been.calledWith( + '/profile/dir/Local State', + sinon.match({ + fake_local_state: { + value: 'value', + }, + }), + ) + }) + + it('merges user preferences with defaults during launch', async function () { + const userPreferences = { + default: { + fake_preference: { + value: 'value', + }, + customSetting: 'userValue', + }, + localState: { + fake_local_state: { + value: 'value', + }, + }, + } + + utils.executeBeforeBrowserLaunch.resolves({ + args: [], + preferences: userPreferences, + }) + + await chrome.open({ isHeadless: true }, 'http://localhost:3000', openOpts, this.automation) + + // Verify that merged preferences were written + expect(this.outputJson).to.have.been.calledWith( + '/profile/dir/Default/Preferences', + sinon.match({ + fake_preference: { + value: 'value', + }, + customSetting: 'userValue', // User addition + }), + ) + + expect(this.outputJson).to.have.been.calledWith( + '/profile/dir/Local State', + sinon.match({ + fake_local_state: { + value: 'value', + }, + }), + ) + }) }) })