misc: Ensure cypress tab is active before any command runs (#28334)

This commit is contained in:
Chris Breiding
2023-11-16 13:09:35 -05:00
committed by GitHub
parent 424d408e26
commit 06b5ca280c
17 changed files with 352 additions and 11 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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({

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}

View 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)
}

View File

@@ -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>

View File

@@ -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"

View 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' }, '*')
}
})

View File

@@ -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"
},

View 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' })
}
})
})

View File

@@ -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,
),

View File

@@ -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",

View 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
})
})
})

View 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)
})
})