Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 14 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/driver/cypress/e2e/cypress/browser.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,27 @@ 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 })
})
})

context('.isBrowser', () => {
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 () {
Expand All @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions packages/driver/src/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion packages/driver/src/cypress/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const _isBrowser = (browser, matcher, errPrefix) => {
let exclusive = false

const matchWithExclusion = (objValue, srcValue) => {
if (srcValue.startsWith('!')) {
if (_.isString(srcValue) && srcValue.startsWith('!')) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a drive-by fix that I needed for the browser check. I didn't see any open issues about this, but it does seem like it wasn't working as documented, where Cypress.isBrowser({ isHeadless: true }) would fail with "startsWith is not a function".

exclusive = true

return objValue !== srcValue.slice(1)
Expand Down
3 changes: 2 additions & 1 deletion packages/driver/src/cypress/command_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/driver/src/cypress/cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -343,6 +344,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
return Cypress.backend('close:extra:targets')
})

handleTabActivation(Cypress)
handleCrossOriginCookies(Cypress)
}

Expand Down
50 changes: 50 additions & 0 deletions packages/driver/src/util/tab_activation.ts
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions packages/extension/app/newtab.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ <h1>Cypress is currently automating this browser.</h1>
<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>
mschile marked this conversation as resolved.
Show resolved Hide resolved
<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>
2 changes: 1 addition & 1 deletion packages/extension/app/v2/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 32 additions & 0 deletions packages/extension/app/v3/content.js
Original file line number Diff line number Diff line change
@@ -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' }, '*')
}
})
27 changes: 25 additions & 2 deletions packages/extension/app/v3/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
45 changes: 45 additions & 0 deletions packages/extension/app/v3/service-worker.js
Original file line number Diff line number Diff line change
@@ -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' })
mschile marked this conversation as resolved.
Show resolved Hide resolved
}
})
})
6 changes: 6 additions & 0 deletions packages/extension/gulpfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -74,6 +79,7 @@ const build = gulp.series(
manifest('v2'),
manifest('v3'),
background,
copyScriptsForV3,
html,
css,
),
Expand Down
3 changes: 2 additions & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down