Skip to content

Commit ed2fc13

Browse files
fix: move main tab activation to puppeteer plugin (#28898)
* fix: move main tab activation to puppeteer plugin * tests for new url functionality in v3 extension * tests for activateMainTab * tests * cleanup * add troubleshooting to puppeteer plugin readme re: chrome extension * changelog * no longer attempts to activate main tab in run mode * Update npm/puppeteer/README.md Co-authored-by: Jennifer Shehane <[email protected]> * Update cli/CHANGELOG.md Co-authored-by: Jennifer Shehane <[email protected]> --------- Co-authored-by: Jennifer Shehane <[email protected]>
1 parent 1f0a9d5 commit ed2fc13

File tree

19 files changed

+639
-359
lines changed

19 files changed

+639
-359
lines changed

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ _Released 2/13/2024 (PENDING)_
55

66
**Bugfixes:**
77

8+
- Fixed tests hanging when the Chrome browser extension is disabled. Fixes [#28392](https://github.com/cypress-io/cypress/issues/28392)
89
- 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).
910
- 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).
1011
- 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).

npm/puppeteer/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,15 @@ export default defineConfig({
333333
})
334334
```
335335
336+
## Troubleshooting
337+
338+
### Error: Cannot communicate with the Cypress Chrome extension. Ensure the extension is enabled when using the Puppeteer plugin.
339+
340+
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.
341+
342+
* Ensure this extension is enabled in the instance of Chrome that Cypress launches by visiting chrome://extensions/
343+
* Ensure the Cypress extension is allowed by your company's security policy by its extension id, `caljajdfkjjjdehjdoimjkkakekklcck`
344+
336345
## Contributing
337346
338347
Build the TypeScript files:

npm/puppeteer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"puppeteer-core": "^21.2.1"
2222
},
2323
"devDependencies": {
24+
"@types/node": "^18.17.5",
2425
"chai-as-promised": "^7.1.1",
2526
"chokidar": "^3.5.3",
2627
"express": "4.17.3",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/// <reference lib="browser">
2+
import type { Browser } from 'puppeteer-core'
3+
4+
export const ACTIVATION_TIMEOUT = 2000
5+
6+
const sendActivationMessage = (activationTimeout: number) => {
7+
// don't need to worry about tabs for Cy in Cy tests
8+
if (document.defaultView !== top) {
9+
return
10+
}
11+
12+
let timeout: NodeJS.Timeout
13+
let onMessage: (ev: MessageEvent) => void
14+
15+
// promise must resolve with a value for chai as promised to test resolution
16+
return new Promise<void>((resolve, reject) => {
17+
onMessage = (ev) => {
18+
if (ev.data.message === 'cypress:extension:main:tab:activated') {
19+
window.removeEventListener('message', onMessage)
20+
clearTimeout(timeout)
21+
resolve()
22+
}
23+
}
24+
25+
window.addEventListener('message', onMessage)
26+
window.postMessage({ message: 'cypress:extension:activate:main:tab' })
27+
28+
timeout = setTimeout(() => {
29+
window.removeEventListener('message', onMessage)
30+
reject()
31+
}, activationTimeout)
32+
})
33+
}
34+
35+
export const activateMainTab = async (browser: Browser) => {
36+
// - Only implemented for Chromium right now. Support for Firefox/webkit
37+
// could be added later
38+
// - Electron doesn't have tabs
39+
// - Focus doesn't matter for headless browsers and old headless Chrome
40+
// doesn't run the extension
41+
const [page] = await browser.pages()
42+
43+
if (page) {
44+
return page.evaluate(sendActivationMessage, ACTIVATION_TIMEOUT)
45+
}
46+
}

npm/puppeteer/src/plugin/setup.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import isPlainObject from 'lodash/isPlainObject'
22
import defaultPuppeteer, { Browser, PuppeteerNode } from 'puppeteer-core'
33
import { pluginError } from './util'
4+
import { activateMainTab } from './activateMainTab'
45

5-
type MessageHandler = (browser: Browser, ...args: any[]) => any | Promise<any>
6+
export type MessageHandler = (browser: Browser, ...args: any[]) => any | Promise<any>
67

78
interface SetupOptions {
89
onMessage: Record<string, MessageHandler>
@@ -61,7 +62,7 @@ export function setup (options: SetupOptions) {
6162
let debuggerUrl: string
6263

6364
try {
64-
options.on('after:browser:launch', async (browser, options) => {
65+
options.on('after:browser:launch', (browser: Cypress.Browser, options: Cypress.AfterBrowserLaunchDetails) => {
6566
cypressBrowser = browser
6667
debuggerUrl = options.webSocketDebuggerUrl
6768
})
@@ -110,6 +111,21 @@ export function setup (options: SetupOptions) {
110111
} catch (err: any) {
111112
error = err
112113
} finally {
114+
// - Only implemented for Chromium right now. Support for Firefox/webkit
115+
// could be added later
116+
// - Electron doesn't have tabs
117+
// - Focus doesn't matter for headless browsers and old headless Chrome
118+
// doesn't run the extension
119+
const isHeadedChromium = cypressBrowser.isHeaded && cypressBrowser.family === 'chromium' && cypressBrowser.name !== 'electron'
120+
121+
if (isHeadedChromium) {
122+
try {
123+
await activateMainTab(browser)
124+
} catch (e) {
125+
return messageHandlerError(pluginError('Cannot communicate with the Cypress Chrome extension. Ensure the extension is enabled when using the Puppeteer plugin.'))
126+
}
127+
}
128+
113129
await browser.disconnect()
114130
}
115131

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { expect, use } from 'chai'
2+
import chaiAsPromised from 'chai-as-promised'
3+
import sinon from 'sinon'
4+
import sinonChai from 'sinon-chai'
5+
import type { Browser, Page } from 'puppeteer-core'
6+
import { activateMainTab, ACTIVATION_TIMEOUT } from '../../src/plugin/activateMainTab'
7+
8+
use(chaiAsPromised)
9+
use(sinonChai)
10+
11+
describe('activateMainTab', () => {
12+
let clock: sinon.SinonFakeTimers
13+
let prevWin: Window
14+
let prevDoc: Document
15+
let prevTop: Window & typeof globalThis
16+
let window: Partial<Window>
17+
let mockDocument: Partial<Document> & {
18+
defaultView: Window & typeof globalThis
19+
}
20+
let mockTop: Partial<Window & typeof globalThis>
21+
let mockBrowser: Partial<Browser>
22+
let mockPage: Partial<Page>
23+
24+
beforeEach(() => {
25+
clock = sinon.useFakeTimers()
26+
27+
window = {
28+
addEventListener: sinon.stub(),
29+
removeEventListener: sinon.stub(),
30+
31+
// @ts-ignore sinon gets confused about postMessage type declaration
32+
postMessage: sinon.stub(),
33+
}
34+
35+
mockDocument = {
36+
defaultView: window as Window & typeof globalThis,
37+
}
38+
39+
mockTop = mockDocument.defaultView
40+
41+
// activateMainTab is eval'd in browser context, but the tests exec in a
42+
// node context. We don't necessarily need to do this swap, but it makes the
43+
// tests more portable.
44+
// @ts-ignore
45+
prevWin = global.window
46+
prevDoc = global.document
47+
// @ts-ignore
48+
prevTop = global.top
49+
//@ts-ignore
50+
global.window = window
51+
global.document = mockDocument as Document
52+
//@ts-ignore
53+
global.top = mockTop
54+
55+
mockPage = {
56+
evaluate: sinon.stub().callsFake((fn, ...args) => fn(...args)),
57+
}
58+
59+
mockBrowser = {
60+
pages: sinon.stub(),
61+
}
62+
})
63+
64+
afterEach(() => {
65+
clock.restore()
66+
// @ts-ignore
67+
global.window = prevWin
68+
// @ts-ignore
69+
global.top = prevTop
70+
global.document = prevDoc
71+
})
72+
73+
it('sends a tab activation request to the plugin, and resolves when the ack event is received', async () => {
74+
const pagePromise = Promise.resolve([mockPage])
75+
76+
;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)
77+
const p = activateMainTab(mockBrowser as Browser)
78+
79+
await pagePromise
80+
// @ts-ignore
81+
window.addEventListener.withArgs('message').yield({ data: { message: 'cypress:extension:main:tab:activated' } })
82+
expect(window.postMessage).to.be.calledWith({ message: 'cypress:extension:activate:main:tab' })
83+
84+
expect(p).to.eventually.be.true
85+
})
86+
87+
it('sends a tab activation request to the plugin, and rejects if it times out', async () => {
88+
const pagePromise = Promise.resolve([mockPage])
89+
90+
;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)
91+
await pagePromise
92+
93+
const p = activateMainTab(mockBrowser as Browser)
94+
95+
clock.tick(ACTIVATION_TIMEOUT + 1)
96+
97+
expect(p).to.be.rejected
98+
})
99+
100+
describe('when cy in cy', () => {
101+
beforeEach(() => {
102+
mockDocument.defaultView = {} as Window & typeof globalThis
103+
})
104+
105+
it('does not try to send tab activation message', async () => {
106+
const pagePromise = Promise.resolve([mockPage])
107+
108+
;(mockBrowser.pages as sinon.SinonStub).returns(pagePromise)
109+
110+
const p = activateMainTab(mockBrowser as Browser)
111+
112+
await pagePromise
113+
expect(window.postMessage).not.to.be.called
114+
expect(window.addEventListener).not.to.be.called
115+
await p
116+
})
117+
})
118+
})

0 commit comments

Comments
 (0)