mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-22 15:11:00 -06:00
misc: Ensure cypress tab is active before any command runs (#28334)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
2
cli/types/cypress.d.ts
vendored
2
cli/types/cypress.d.ts
vendored
@@ -818,7 +818,7 @@ declare namespace Cypress {
|
||||
* Trigger action
|
||||
* @private
|
||||
*/
|
||||
action: (action: string, ...args: any[]) => any[] | void
|
||||
action: <T = (any[] | void)>(action: string, ...args: any[]) => T
|
||||
|
||||
/**
|
||||
* Load files
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -468,7 +468,8 @@ export class CommandQueue extends Queue<$Command> {
|
||||
|
||||
Cypress.action('cy:command:start', command)
|
||||
|
||||
return this.runCommand(command)!
|
||||
return Cypress.action<Promise<void>>('cy:command:start:async', command)
|
||||
.then(() => this.runCommand(command)!)
|
||||
.then(() => {
|
||||
// each successful command invocation should
|
||||
// always reset the timeout for the current runnable
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
50
packages/driver/src/util/tab_activation.ts
Normal file
50
packages/driver/src/util/tab_activation.ts
Normal file
@@ -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<void>((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)
|
||||
}
|
||||
@@ -13,11 +13,11 @@
|
||||
<p>Opening new tabs may interfere with tests and cause failures.</p>
|
||||
<p>Please note:</p>
|
||||
<ul>
|
||||
<li>Any opened tabs will be closed when Cypress is stopped.</li>
|
||||
<li>Any opened tabs will be closed between tests.</li>
|
||||
<li>Tests currently running may fail while another tab has focus.</li>
|
||||
<li>Cookies and session from other sites will be cleared.</li>
|
||||
</ul>
|
||||
<a href="https://on.cypress.io/launching-browsers" target="_blank">Read more about browser management</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
32
packages/extension/app/v3/content.js
Normal file
32
packages/extension/app/v3/content.js
Normal file
@@ -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' }, '*')
|
||||
}
|
||||
})
|
||||
@@ -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://*/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"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://*/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "newtab.html"
|
||||
},
|
||||
|
||||
45
packages/extension/app/v3/service-worker.js
Normal file
45
packages/extension/app/v3/service-worker.js
Normal file
@@ -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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
86
packages/extension/test/integration/content_spec.js
Normal file
86
packages/extension/test/integration/content_spec.js
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
88
packages/extension/test/integration/service-worker_spec.js
Normal file
88
packages/extension/test/integration/service-worker_spec.js
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user