Skip to content

Commit

Permalink
misc: Ensure cypress tab is active before any command runs (#28334)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisbreiding committed Nov 16, 2023
1 parent 424d408 commit 06b5ca2
Show file tree
Hide file tree
Showing 17 changed files with 352 additions and 11 deletions.
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('!')) {
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>
<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' })
}
})
})
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

5 comments on commit 06b5ca2

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 06b5ca2 Nov 16, 2023

Choose a reason for hiding this comment

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

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.0/linux-x64/develop-06b5ca280cdafba2fd626d1c0fede7bddea8730c/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 06b5ca2 Nov 16, 2023

Choose a reason for hiding this comment

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

Circle has built the linux arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.0/linux-arm64/develop-06b5ca280cdafba2fd626d1c0fede7bddea8730c/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 06b5ca2 Nov 16, 2023

Choose a reason for hiding this comment

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

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.0/darwin-x64/develop-06b5ca280cdafba2fd626d1c0fede7bddea8730c/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 06b5ca2 Nov 16, 2023

Choose a reason for hiding this comment

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

Circle has built the darwin arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.0/darwin-arm64/develop-06b5ca280cdafba2fd626d1c0fede7bddea8730c/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 06b5ca2 Nov 16, 2023

Choose a reason for hiding this comment

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

Circle has built the win32 x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/13.6.0/win32-x64/develop-06b5ca280cdafba2fd626d1c0fede7bddea8730c/cypress.tgz

Please sign in to comment.