diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0c5cc03b7d..224530ac6b 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -10,6 +10,7 @@ _Released 11/21/2023 (PENDING)_ **Misc:** - Browser tabs and windows other than the Cypress tab are now closed between tests in Chromium-based browsers. Addressed in [#28204](https://github.com/cypress-io/cypress/pull/28204). +- Cypress now ensures the main browser tab is active before running eaech command in Chromium-based browsers. Addressed in [#28334](https://github.com/cypress-io/cypress/pull/28334). ## 13.5.1 diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index f4c69ec165..d4bfbf3703 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -818,7 +818,7 @@ declare namespace Cypress { * Trigger action * @private */ - action: (action: string, ...args: any[]) => any[] | void + action: (action: string, ...args: any[]) => T /** * Load files diff --git a/packages/driver/cypress/e2e/cypress/browser.cy.js b/packages/driver/cypress/e2e/cypress/browser.cy.js index 3810f5fa27..53acfa458f 100644 --- a/packages/driver/cypress/e2e/cypress/browser.cy.js +++ b/packages/driver/cypress/e2e/cypress/browser.cy.js @@ -2,14 +2,14 @@ import browserProps from '@packages/driver/src/cypress/browser' describe('src/cypress/browser', () => { beforeEach(function () { - this.commands = (browser = { name: 'chrome', family: 'chromium' }) => { + this.commands = (browser = { name: 'chrome', family: 'chromium', isHeadless: false }) => { return browserProps({ browser }) } }) context('.browser', () => { it('returns the current browser', function () { - expect(this.commands().browser).to.eql({ name: 'chrome', family: 'chromium' }) + expect(this.commands().browser).to.eql({ name: 'chrome', family: 'chromium', isHeadless: false }) }) }) @@ -17,10 +17,12 @@ describe('src/cypress/browser', () => { it('returns true if it\'s a match', function () { expect(this.commands().isBrowser('chrome')).to.be.true expect(this.commands().isBrowser({ family: 'chromium' })).to.be.true + expect(this.commands().isBrowser({ isHeadless: false })).to.be.true }) it('returns false if it\'s not a match', function () { expect(this.commands().isBrowser('firefox')).to.be.false + expect(this.commands().isBrowser({ isHeadless: true })).to.be.false }) it('is case-insensitive', function () { @@ -33,6 +35,7 @@ describe('src/cypress/browser', () => { expect(this.commands().isBrowser({ family: 'chromium', name: '!firefox', + isHeadless: false, })).to.be['true'] expect(this.commands().isBrowser({ diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 34d5351e93..50133006d2 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -647,6 +647,9 @@ class $Cypress { case 'cy:command:start': return this.emit('command:start', ...args) + case 'cy:command:start:async': + return this.emitThen('command:start:async', ...args) + case 'cy:command:end': return this.emit('command:end', ...args) diff --git a/packages/driver/src/cypress/browser.ts b/packages/driver/src/cypress/browser.ts index a3d188955c..6fcaa03526 100644 --- a/packages/driver/src/cypress/browser.ts +++ b/packages/driver/src/cypress/browser.ts @@ -7,7 +7,7 @@ const _isBrowser = (browser, matcher, errPrefix) => { let exclusive = false const matchWithExclusion = (objValue, srcValue) => { - if (srcValue.startsWith('!')) { + if (_.isString(srcValue) && srcValue.startsWith('!')) { exclusive = true return objValue !== srcValue.slice(1) diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index be28d47858..3b692c51b4 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -468,7 +468,8 @@ export class CommandQueue extends Queue<$Command> { Cypress.action('cy:command:start', command) - return this.runCommand(command)! + return Cypress.action>('cy:command:start:async', command) + .then(() => this.runCommand(command)!) .then(() => { // each successful command invocation should // always reset the timeout for the current runnable diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 23dfc479ea..5c87e0d05c 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -32,6 +32,7 @@ import { create as createOverrides, IOverrides } from '../cy/overrides' import { historyNavigationTriggeredHashChange } from '../cy/navigation' import { EventEmitter2 } from 'eventemitter2' import { handleCrossOriginCookies } from '../cross-origin/events/cookies' +import { handleTabActivation } from '../util/tab_activation' import type { ICypress } from '../cypress' import type { ICookies } from './cookies' @@ -343,6 +344,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert return Cypress.backend('close:extra:targets') }) + handleTabActivation(Cypress) handleCrossOriginCookies(Cypress) } diff --git a/packages/driver/src/util/tab_activation.ts b/packages/driver/src/util/tab_activation.ts new file mode 100644 index 0000000000..f6f8bf0d87 --- /dev/null +++ b/packages/driver/src/util/tab_activation.ts @@ -0,0 +1,50 @@ +import type { ICypress } from '../cypress' + +const isCypressInCypress = document.defaultView !== top + +function activateMainTab () { + // Don't need to activate the main tab if it already has focus + if (document.hasFocus()) return + + return new Promise((resolve) => { + const url = `${window.location.origin}${window.location.pathname}` + + // This sends a message on the window that the extension content script + // listens for in order to carry out activating the main tab + window.postMessage({ message: 'cypress:extension:activate:main:tab', url }, '*') + + function onMessage ({ data, source }) { + // only accept messages from ourself + if (source !== window) return + + if (data.message === 'cypress:extension:main:tab:activated') { + window.removeEventListener('message', onMessage) + + resolve() + } + } + + // The reply from the extension comes back via the same means, a message + // sent on the window + window.addEventListener('message', onMessage) + }) +} + +// Ensures the main Cypress tab has focus before every command +// and at the end of the test run +export function handleTabActivation (Cypress: ICypress) { + // - Only implemented for Chromium right now. Support for Firefox/webkit + // could be added later + // - Electron doesn't have tabs + // - Focus doesn't matter for headless browsers and old headless Chrome + // doesn't run the extension + // - Don't need to worry about tabs for Cypress in Cypress tests (and they + // can't currently communicate with the extension anyway) + if ( + !Cypress.isBrowser({ family: 'chromium', name: '!electron', isHeadless: false }) + || isCypressInCypress + ) return + + Cypress.on('command:start:async', activateMainTab) + Cypress.on('test:after:run:async', activateMainTab) +} diff --git a/packages/extension/app/newtab.html b/packages/extension/app/newtab.html index d1c282681b..90f66e4204 100644 --- a/packages/extension/app/newtab.html +++ b/packages/extension/app/newtab.html @@ -13,11 +13,11 @@

Opening new tabs may interfere with tests and cause failures.

Please note:

    -
  • Any opened tabs will be closed when Cypress is stopped.
  • +
  • Any opened tabs will be closed between tests.
  • Tests currently running may fail while another tab has focus.
  • Cookies and session from other sites will be cleared.
Read more about browser management - \ No newline at end of file + diff --git a/packages/extension/app/v2/manifest.json b/packages/extension/app/v2/manifest.json index c8200dd99c..d48267970d 100644 --- a/packages/extension/app/v2/manifest.json +++ b/packages/extension/app/v2/manifest.json @@ -1,6 +1,6 @@ { "name": "Cypress", - "description": "Adds WebExtension APIs for testing with Cypress", + "description": "Adds theme and WebExtension APIs for testing with Cypress", "applications": { "gecko": { "id": "automation-extension@cypress.io" diff --git a/packages/extension/app/v3/content.js b/packages/extension/app/v3/content.js new file mode 100644 index 0000000000..f30ab8034b --- /dev/null +++ b/packages/extension/app/v3/content.js @@ -0,0 +1,32 @@ +/* global chrome, window */ + +// this content script has access to the DOM, but is otherwise isolated from +// the page running Cypress, so we have to use postMessage to communicate. it +// also doesn't have direct access to the extension API, so we use the +// messaging API it can access to communicate with the background service +// worker script. so essentially, it's an intermediary between Cypress and +// the extension background script +const port = chrome.runtime.connect() + +// this listens for messages from the main window that Cypress runs on. it's +// a very global message bus, so messages could come from a variety of sources +window.addEventListener('message', ({ data, source }) => { + // only accept messages from ourself + if (source !== window) return + + // this is the only message we're currently interested in, which tells us + // to activate the main tab + if (data.message === 'cypress:extension:activate:main:tab') { + port.postMessage({ message: 'activate:main:tab', url: data.url }) + } +}) + +// this listens for messages from the background service worker script +port.onMessage.addListener(({ message }) => { + // this lets us know the message we sent to the background script to activate + // the main tab was successful, so we in turn send it on to Cypress + // via postMessage + if (message === 'main:tab:activated') { + window.postMessage({ message: 'cypress:extension:main:tab:activated' }, '*') + } +}) diff --git a/packages/extension/app/v3/manifest.json b/packages/extension/app/v3/manifest.json index a7413a18da..3460edc44b 100644 --- a/packages/extension/app/v3/manifest.json +++ b/packages/extension/app/v3/manifest.json @@ -1,12 +1,20 @@ { "name": "Cypress", - "description": "Adds Themes for testing with Cypress", + "description": "Adds theme and WebExtension APIs for testing with Cypress", "applications": { "gecko": { "id": "automation-extension-v3@cypress.io" } }, - "permissions": [], + "permissions": [ + "tabs" + ], + "host_permissions": [ + "http://*/*", + "https://*/*", + "" + ], + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAugoxpSqfoblTYUGvyXZpmBgjYQUY9k2Hx3PaDwquyaTH6GBxitwVMSu5sZuDYgPHpGYoF4ol6A4PZHhd6JvfuUDS9ZrxTW0XzP+dSS9AwmJo3uLuP88zBs4mhpje1+WE5NGM0pTzyCXYTPoyzyPRmToALWD96cahSGuhG8bSmaBw3py+16qNKm8SOlANbUvHtEaTpmrSWBUIq7YV8SIPLtR8G47vjqPTE1yEsBQ3GAgllhi0cJolwk/629fRLr3KVckICmU6spXD/jVhIgAeyHhFuFGYNuubzbel8trBVw5Q/HE5F6j66sBvEvW64tH4lPxnM5JPv0qie5wouPiT0wIDAQAB", "icons": { "16": "icons/icon_16x16.png", "48": "icons/icon_48x48.png", @@ -20,6 +28,21 @@ }, "default_popup": "popup.html" }, + "background": { + "service_worker": "service-worker.js" + }, + "content_scripts": [ + { + "matches": [ + "http://*/*", + "https://*/*", + "" + ], + "js": [ + "content.js" + ] + } + ], "chrome_url_overrides": { "newtab": "newtab.html" }, diff --git a/packages/extension/app/v3/service-worker.js b/packages/extension/app/v3/service-worker.js new file mode 100644 index 0000000000..71374bdece --- /dev/null +++ b/packages/extension/app/v3/service-worker.js @@ -0,0 +1,45 @@ +/* global chrome */ + +// this background script runs in a service worker. it has access to the +// extension API, but not direct access the web page or anything else +// running in the browser + +// to debug this script, go to `chrome://inspect` in a new Chrome tab, +// select Service Workers on the left and click `inspect`. to reload changes +// go to `chrome://extensions` and hit the reload button under the Cypress +// extension. sometimes that doesn't work and requires re-launching Chrome +// and then reloading the extension via `chrome://extensions` + +async function activateMainTab (url) { + try { + const tabs = await chrome.tabs.query({}) + + const cypressTab = tabs.find((tab) => tab.url.includes(url)) + + if (!cypressTab) return + + // this brings the main Cypress tab to the front of any other tabs + // without Chrome stealing focus from other running apps + await chrome.tabs.update(cypressTab.id, { active: true }) + } catch (err) { + // ignore the error but log it. these logs only appear if you inspect + // the service worker, so it won't clutter up the console for users + + // eslint-disable-next-line no-console + console.log('Activating main Cypress tab errored:', err) + } +} + +// here we connect to the content script, which has access to the web page +// running Cypress, but not the extension API +chrome.runtime.onConnect.addListener((port) => { + port.onMessage.addListener(async ({ message, url }) => { + if (message === 'activate:main:tab') { + await activateMainTab(url) + + // send an ack back to let the content script know we successfully + // activated the main tab + port.postMessage({ message: 'main:tab:activated' }) + } + }) +}) diff --git a/packages/extension/gulpfile.ts b/packages/extension/gulpfile.ts index 874de8ebdb..12e99444ee 100644 --- a/packages/extension/gulpfile.ts +++ b/packages/extension/gulpfile.ts @@ -29,6 +29,11 @@ const background = (cb) => { }) } +const copyScriptsForV3 = () => { + return gulp.src('app/v3/*.js') + .pipe(gulp.dest('dist/v3')) +} + const html = () => { return gulp.src('app/**/*.html') .pipe(gulp.dest('dist/v2')) @@ -74,6 +79,7 @@ const build = gulp.series( manifest('v2'), manifest('v3'), background, + copyScriptsForV3, html, css, ), diff --git a/packages/extension/package.json b/packages/extension/package.json index dfa675805d..c3936878fb 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -12,7 +12,7 @@ "test-debug": "yarn test-unit --inspect-brk=5566", "test-unit": "cross-env NODE_ENV=test mocha -r @packages/ts/register --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json", "test-watch": "yarn test-unit --watch", - "watch": "node ../../scripts/run-webpack --watch --progress", + "watch": "yarn build && chokidar 'app/**/*.*' 'app/*.*' -c 'yarn build'", "lint": "eslint --ext .js,.jsx,.ts,.tsx,.json, ." }, "dependencies": { @@ -23,6 +23,7 @@ "@packages/icons": "0.0.0-development", "@packages/socket": "0.0.0-development", "chai": "3.5.0", + "chokidar-cli": "2.1.0", "cross-env": "6.0.3", "eol": "0.9.1", "fs-extra": "9.1.0", diff --git a/packages/extension/test/integration/content_spec.js b/packages/extension/test/integration/content_spec.js new file mode 100644 index 0000000000..e949cffd3f --- /dev/null +++ b/packages/extension/test/integration/content_spec.js @@ -0,0 +1,86 @@ +require('../spec_helper') + +describe('app/v3/content', () => { + let port + let chrome + let window + + before(() => { + port = { + onMessage: { + addListener: sinon.stub(), + }, + postMessage: sinon.stub(), + } + + chrome = { + runtime: { + connect: sinon.stub().returns(port), + }, + } + + global.chrome = chrome + + window = { + addEventListener: sinon.stub(), + postMessage: sinon.stub(), + }, + + global.window = window + + require('../../app/v3/content') + }) + + beforeEach(() => { + port.postMessage.reset() + window.postMessage.reset() + }) + + it('adds window message listener and port onMessage listener', () => { + expect(window.addEventListener).to.be.calledWith('message', sinon.match.func) + expect(port.onMessage.addListener).to.be.calledWith(sinon.match.func) + }) + + describe('messages from window (i.e Cypress)', () => { + it('posts message to port if message is cypress:extension:activate:main:tab', () => { + const data = { message: 'cypress:extension:activate:main:tab', url: 'the://url' } + + window.addEventListener.yield({ data, source: window }) + + expect(port.postMessage).to.be.calledWith({ + message: 'activate:main:tab', + url: 'the://url', + }) + }) + + it('is a noop if source is not the same window', () => { + window.addEventListener.yield({ source: {} }) + + expect(port.postMessage).not.to.be.called + }) + + it('is a noop if message is not cypress:extension:activate:main:tab', () => { + const data = { message: 'unsupported' } + + window.addEventListener.yield({ data, source: window }) + + expect(port.postMessage).not.to.be.called + }) + }) + + describe('messages from port (i.e. service worker)', () => { + it('posts message to window', () => { + port.onMessage.addListener.yield({ message: 'main:tab:activated' }) + + expect(window.postMessage).to.be.calledWith({ message: 'cypress:extension:main:tab:activated' }, '*') + }) + + it('is a noop if message is not main:tab:activated', () => { + const data = { message: 'unsupported' } + + port.onMessage.addListener.yield({ data, source: window }) + + expect(window.postMessage).not.to.be.called + }) + }) +}) diff --git a/packages/extension/test/integration/service-worker_spec.js b/packages/extension/test/integration/service-worker_spec.js new file mode 100644 index 0000000000..6ea25e6150 --- /dev/null +++ b/packages/extension/test/integration/service-worker_spec.js @@ -0,0 +1,88 @@ +require('../spec_helper') + +describe('app/v3/service-worker', () => { + let chrome + let port + + before(() => { + chrome = { + runtime: { + onConnect: { + addListener: sinon.stub(), + }, + }, + tabs: { + query: sinon.stub(), + update: sinon.stub(), + }, + } + + global.chrome = chrome + + require('../../app/v3/service-worker') + }) + + beforeEach(() => { + chrome.tabs.query.reset() + chrome.tabs.update.reset() + + port = { + onMessage: { + addListener: sinon.stub(), + }, + postMessage: sinon.stub(), + } + }) + + it('adds onConnect listener', () => { + expect(chrome.runtime.onConnect.addListener).to.be.calledWith(sinon.match.func) + }) + + it('adds port onMessage listener', () => { + chrome.runtime.onConnect.addListener.yield(port) + + expect(port.onMessage.addListener).to.be.calledWith(sinon.match.func) + }) + + it('updates the tab matching the url', async () => { + chrome.runtime.onConnect.addListener.yield(port) + chrome.tabs.query.resolves([{ id: 'tab-id', url: 'the://url' }]) + + await port.onMessage.addListener.yield({ message: 'activate:main:tab', url: 'the://url' })[0] + + expect(chrome.tabs.update).to.be.calledWith('tab-id', { active: true }) + }) + + it('is a noop if message is not activate:main:tab', async () => { + chrome.runtime.onConnect.addListener.yield(port) + + await port.onMessage.addListener.yield({ message: 'unsupported' })[0] + + expect(chrome.tabs.update).not.to.be.called + }) + + it('is a noop if url does not match a tab', async () => { + chrome.runtime.onConnect.addListener.yield(port) + chrome.tabs.query.resolves([{ id: 'tab-id', url: 'the://url' }]) + + await port.onMessage.addListener.yield({ message: 'activate:main:tab', url: 'different://url' })[0] + + expect(chrome.tabs.update).not.to.be.called + }) + + it('is a noop, logging the error, if activating the tab errors', async () => { + sinon.spy(console, 'log') + + chrome.runtime.onConnect.addListener.yield(port) + chrome.tabs.query.resolves([{ id: 'tab-id', url: 'the://url' }]) + + const err = new Error('uh oh') + + chrome.tabs.update.rejects(err) + + await port.onMessage.addListener.yield({ message: 'activate:main:tab', url: 'the://url' })[0] + + // eslint-disable-next-line no-console + expect(console.log).to.be.calledWith('Activating main Cypress tab errored:', err) + }) +})