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

fix: move main tab activation to puppeteer plugin #28898

Merged
merged 17 commits into from
Feb 20, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
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 @@ -5,6 +5,7 @@ _Released 2/13/2024 (PENDING)_

**Bugfixes:**

- Fixed tests hanging when the Chrome browser extension is disabled. Fixes [#28392](https://github.com/cypress-io/cypress/issues/28392)
- Fixed an issue which caused the browser to relaunch after closing the browser from the Launchpad. Fixes [#28852](https://github.com/cypress-io/cypress/issues/28852).
- Fixed an issue with the unzip promise never being rejected when an empty error happens. Fixed in [#28850](https://github.com/cypress-io/cypress/pull/28850).
- Fixed a regression introduced in [`13.6.3`](https://docs.cypress.io/guides/references/changelog#13.6.3) where Cypress could crash when processing service worker requests through our proxy. Fixes [#28950](https://github.com/cypress-io/cypress/issues/28950).
Expand Down
9 changes: 9 additions & 0 deletions npm/puppeteer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ export default defineConfig({
})
```

## Troubleshooting

### Error: Cannot communicate with the Cypress Chrome extension. Ensure the extension is enabled when using the Puppeteer plugin.

If you receive this error in your command log, the Puppeteer plugin was unable to communicate with the Cypress extension. This extension is necessary in order to re-activate the main Cypress tab after a Puppeteer command, when running in open mode.

* Ensure this extension is enabled in the instance of Chrome that Cypress launches by visiting chrome://extensions/
* Ensure the Cypress extension is allowed by your company's security policy by its extension id, `caljajdfkjjjdehjdoimjkkakekklcck`

## Contributing

Build the TypeScript files:
Expand Down
1 change: 1 addition & 0 deletions npm/puppeteer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"puppeteer-core": "^21.2.1"
},
"devDependencies": {
"@types/node": "^18.17.5",
"chai-as-promised": "^7.1.1",
"chokidar": "^3.5.3",
"express": "4.17.3",
Expand Down
46 changes: 46 additions & 0 deletions npm/puppeteer/src/plugin/activateMainTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/// <reference lib="browser">
import type { Browser } from 'puppeteer-core'

export const ACTIVATION_TIMEOUT = 2000

const sendActivationMessage = (activationTimeout: number) => {
// don't need to worry about tabs for Cy in Cy tests
if (document.defaultView !== top) {
return
}

let timeout: NodeJS.Timeout
let onMessage: (ev: MessageEvent) => void

// promise must resolve with a value for chai as promised to test resolution
return new Promise<void>((resolve, reject) => {
onMessage = (ev) => {
if (ev.data.message === 'cypress:extension:main:tab:activated') {
window.removeEventListener('message', onMessage)
clearTimeout(timeout)
resolve()
}
}

window.addEventListener('message', onMessage)
window.postMessage({ message: 'cypress:extension:activate:main:tab' })
Copy link
Contributor

Choose a reason for hiding this comment

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

whats the difference between cypress:extension:main:tab:activated and cypress:extension:activate:main:tab?

Copy link
Contributor

Choose a reason for hiding this comment

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

cypress:extension:activate:main:tab is sent to the extension to tell it to activate the main tab. cypress:extension:main:tab:activated is the ack that the main tab was successfully activated.


timeout = setTimeout(() => {
window.removeEventListener('message', onMessage)
reject()
}, activationTimeout)
})
}

export const activateMainTab = async (browser: Browser) => {
// - 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
const [page] = await browser.pages()

if (page) {
return page.evaluate(sendActivationMessage, ACTIVATION_TIMEOUT)
}
}
20 changes: 18 additions & 2 deletions npm/puppeteer/src/plugin/setup.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import isPlainObject from 'lodash/isPlainObject'
import defaultPuppeteer, { Browser, PuppeteerNode } from 'puppeteer-core'
import { pluginError } from './util'
import { activateMainTab } from './activateMainTab'

type MessageHandler = (browser: Browser, ...args: any[]) => any | Promise<any>
export type MessageHandler = (browser: Browser, ...args: any[]) => any | Promise<any>

interface SetupOptions {
onMessage: Record<string, MessageHandler>
Expand Down Expand Up @@ -61,7 +62,7 @@ export function setup (options: SetupOptions) {
let debuggerUrl: string

try {
options.on('after:browser:launch', async (browser, options) => {
options.on('after:browser:launch', (browser: Cypress.Browser, options: Cypress.AfterBrowserLaunchDetails) => {
cypressBrowser = browser
debuggerUrl = options.webSocketDebuggerUrl
})
Expand Down Expand Up @@ -110,6 +111,21 @@ export function setup (options: SetupOptions) {
} catch (err: any) {
error = err
} finally {
// - 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
const isHeadedChromium = cypressBrowser.isHeaded && cypressBrowser.family === 'chromium' && cypressBrowser.name !== 'electron'

if (isHeadedChromium) {
try {
await activateMainTab(browser)
} catch (e) {
return messageHandlerError(pluginError('Cannot communicate with the Cypress Chrome extension. Ensure the extension is enabled when using the Puppeteer plugin.'))
}
}

await browser.disconnect()
}

Expand Down
118 changes: 118 additions & 0 deletions npm/puppeteer/test/unit/activateMainTab.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, use } from 'chai'
import chaiAsPromised from 'chai-as-promised'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import type { Browser, Page } from 'puppeteer-core'
import { activateMainTab, ACTIVATION_TIMEOUT } from '../../src/plugin/activateMainTab'

use(chaiAsPromised)
use(sinonChai)

describe('activateMainTab', () => {
let clock: sinon.SinonFakeTimers
let prevWin: Window
let prevDoc: Document
let prevTop: Window & typeof globalThis
let window: Partial<Window>
let mockDocument: Partial<Document> & {
defaultView: Window & typeof globalThis
}
let mockTop: Partial<Window & typeof globalThis>
let mockBrowser: Partial<Browser>
let mockPage: Partial<Page>

beforeEach(() => {
clock = sinon.useFakeTimers()

window = {
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),

// @ts-ignore sinon gets confused about postMessage type declaration
postMessage: sinon.stub(),
}

mockDocument = {
defaultView: window as Window & typeof globalThis,
}

mockTop = mockDocument.defaultView

// activateMainTab is eval'd in browser context, but the tests exec in a
// node context. We don't necessarily need to do this swap, but it makes the
// tests more portable.
// @ts-ignore
prevWin = global.window
prevDoc = global.document
// @ts-ignore
prevTop = global.top
//@ts-ignore
global.window = window
global.document = mockDocument as Document
//@ts-ignore
global.top = mockTop

mockPage = {
evaluate: sinon.stub().callsFake((fn, ...args) => fn(...args)),
}

mockBrowser = {
pages: sinon.stub(),
}
})

afterEach(() => {
clock.restore()
// @ts-ignore
global.window = prevWin
// @ts-ignore
global.top = prevTop
global.document = prevDoc
})

it('sends a tab activation request to the plugin, and resolves when the ack event is received', async () => {
const pagePromise = Promise.resolve([mockPage])

;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)
const p = activateMainTab(mockBrowser as Browser)

await pagePromise
// @ts-ignore
window.addEventListener.withArgs('message').yield({ data: { message: 'cypress:extension:main:tab:activated' } })
expect(window.postMessage).to.be.calledWith({ message: 'cypress:extension:activate:main:tab' })

expect(p).to.eventually.be.true
})

it('sends a tab activation request to the plugin, and rejects if it times out', async () => {
const pagePromise = Promise.resolve([mockPage])

;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)
await pagePromise

const p = activateMainTab(mockBrowser as Browser)

clock.tick(ACTIVATION_TIMEOUT + 1)

expect(p).to.be.rejected
})

describe('when cy in cy', () => {
beforeEach(() => {
mockDocument.defaultView = {} as Window & typeof globalThis
})

it('does not try to send tab activation message', async () => {
const pagePromise = Promise.resolve([mockPage])

;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)

const p = activateMainTab(mockBrowser as Browser)

await pagePromise
expect(window.postMessage).not.to.be.called
expect(window.addEventListener).not.to.be.called
await p
})
})
})
Loading
Loading