From ed2fc1394623f08097d180747712c557d867ee86 Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Tue, 20 Feb 2024 11:28:31 -0500 Subject: [PATCH] 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 * Update cli/CHANGELOG.md Co-authored-by: Jennifer Shehane --------- Co-authored-by: Jennifer Shehane --- cli/CHANGELOG.md | 1 + npm/puppeteer/README.md | 9 + npm/puppeteer/package.json | 1 + npm/puppeteer/src/plugin/activateMainTab.ts | 46 ++++ npm/puppeteer/src/plugin/setup.ts | 20 +- .../test/unit/activateMainTab.spec.ts | 118 ++++++++ npm/puppeteer/test/unit/setup.spec.ts | 260 ++++++++++-------- npm/puppeteer/tsconfig.json | 3 +- packages/driver/src/cypress/cy.ts | 7 +- packages/driver/src/util/tab_activation.ts | 50 ---- packages/driver/src/util/trackTopUrl.ts | 21 ++ packages/extension/app/v3/content.js | 10 +- packages/extension/app/v3/manifest.json | 10 +- packages/extension/app/v3/service-worker.js | 17 +- .../test/integration/content_spec.js | 86 ------ .../test/integration/service-worker_spec.js | 88 ------ .../integration/{ => v2}/background_spec.js | 6 +- .../test/integration/v3/content_spec.js | 108 ++++++++ .../integration/v3/service-worker_spec.js | 137 +++++++++ 19 files changed, 639 insertions(+), 359 deletions(-) create mode 100644 npm/puppeteer/src/plugin/activateMainTab.ts create mode 100644 npm/puppeteer/test/unit/activateMainTab.spec.ts delete mode 100644 packages/driver/src/util/tab_activation.ts create mode 100644 packages/driver/src/util/trackTopUrl.ts delete mode 100644 packages/extension/test/integration/content_spec.js delete mode 100644 packages/extension/test/integration/service-worker_spec.js rename packages/extension/test/integration/{ => v2}/background_spec.js (99%) create mode 100644 packages/extension/test/integration/v3/content_spec.js create mode 100644 packages/extension/test/integration/v3/service-worker_spec.js diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index bd5751bf4dbf..6119de23bf18 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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). diff --git a/npm/puppeteer/README.md b/npm/puppeteer/README.md index 3e1f1c9448d7..31063f2ae4ea 100644 --- a/npm/puppeteer/README.md +++ b/npm/puppeteer/README.md @@ -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: diff --git a/npm/puppeteer/package.json b/npm/puppeteer/package.json index 8c789277725e..717ba7c4857b 100644 --- a/npm/puppeteer/package.json +++ b/npm/puppeteer/package.json @@ -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", diff --git a/npm/puppeteer/src/plugin/activateMainTab.ts b/npm/puppeteer/src/plugin/activateMainTab.ts new file mode 100644 index 000000000000..8d435b6fab21 --- /dev/null +++ b/npm/puppeteer/src/plugin/activateMainTab.ts @@ -0,0 +1,46 @@ +/// +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((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' }) + + 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) + } +} diff --git a/npm/puppeteer/src/plugin/setup.ts b/npm/puppeteer/src/plugin/setup.ts index 999e52c45546..3f0d1f513379 100644 --- a/npm/puppeteer/src/plugin/setup.ts +++ b/npm/puppeteer/src/plugin/setup.ts @@ -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 +export type MessageHandler = (browser: Browser, ...args: any[]) => any | Promise interface SetupOptions { onMessage: Record @@ -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 }) @@ -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() } diff --git a/npm/puppeteer/test/unit/activateMainTab.spec.ts b/npm/puppeteer/test/unit/activateMainTab.spec.ts new file mode 100644 index 000000000000..5089e4dd159d --- /dev/null +++ b/npm/puppeteer/test/unit/activateMainTab.spec.ts @@ -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 + let mockDocument: Partial & { + defaultView: Window & typeof globalThis + } + let mockTop: Partial + let mockBrowser: Partial + let mockPage: Partial + + 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 + }) + }) +}) diff --git a/npm/puppeteer/test/unit/setup.spec.ts b/npm/puppeteer/test/unit/setup.spec.ts index a2b1ee1d16d2..f33b4dc7f8b1 100644 --- a/npm/puppeteer/test/unit/setup.spec.ts +++ b/npm/puppeteer/test/unit/setup.spec.ts @@ -1,23 +1,61 @@ import { expect, use } from 'chai' import chaiAsPromised from 'chai-as-promised' -import type { PuppeteerNode } from 'puppeteer-core' +import type { PuppeteerNode, Browser } from 'puppeteer-core' import sinon from 'sinon' import sinonChai from 'sinon-chai' - +import { MessageHandler } from '../../src/plugin/setup' import { setup } from '../../src/plugin' +import * as activateMainTabExport from '../../src/plugin/activateMainTab' use(chaiAsPromised) use(sinonChai) -function getTask (on: sinon.SinonStub) { - return on.withArgs('task').lastCall.args[1].__cypressPuppeteer__ -} +type StubbedMessageHandler = sinon.SinonStub, ReturnType> describe('#setup', () => { - it('registers `after:browser:launch` and `task` handlers', () => { - const on = sinon.stub() + let mockBrowser: Partial + let mockPuppeteer: Pick + let on: sinon.SinonStub + let onMessage: Record + + const testTask = 'test' + let testTaskHandler: StubbedMessageHandler + + function getTask () { + return on.withArgs('task').lastCall.args[1].__cypressPuppeteer__ + } + + function simulateBrowserLaunch () { + return on.withArgs('after:browser:launch').yield({ family: 'chromium', isHeaded: true }, { webSocketDebuggerUrl: 'ws://debugger' }) + } + + beforeEach(() => { + sinon.stub(activateMainTabExport, 'activateMainTab') + mockBrowser = { + disconnect: sinon.stub().resolves(), + } - setup({ on, onMessage: {} }) + mockPuppeteer = { + connect: sinon.stub().resolves(mockBrowser), + } + + on = sinon.stub() + + testTaskHandler = sinon.stub() + + onMessage = { + [testTask]: testTaskHandler, + } + }) + + afterEach(() => { + sinon.reset() + + ;(activateMainTabExport.activateMainTab as sinon.SinonStub).restore() + }) + + it('registers `after:browser:launch` and `task` handlers', () => { + setup({ on, onMessage }) expect(on).to.be.calledWith('after:browser:launch') expect(on).to.be.calledWith('task') @@ -27,137 +65,102 @@ describe('#setup', () => { const error = new Error('Event not registered') error.stack = '' - const on = sinon.stub().throws(error) + on.throws(error) - expect(() => setup({ on, onMessage: {} })).to.throw('Could not set up `after:browser:launch` task. Ensure you are running Cypress >= 13.6.0. The following error was encountered:\n\n') + expect(() => setup({ on, onMessage })).to.throw('Could not set up `after:browser:launch` task. Ensure you are running Cypress >= 13.6.0. The following error was encountered:\n\n') }) describe('running message handler', () => { it('connects puppeteer to browser', async () => { - const on = sinon.stub() - const puppeteer = { - connect: sinon.stub().resolves({ - disconnect () {}, - }), - } - setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: sinon.stub() }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) + const task = getTask() - await task({ name: 'test', args: [] }) + await task({ name: testTask, args: [] }) - expect(puppeteer.connect).to.be.calledWith({ + expect(mockPuppeteer.connect).to.be.calledWith({ browserWSEndpoint: 'ws://debugger', defaultViewport: null, }) }) it('calls the specified message handler with the browser and args', async () => { - const on = sinon.stub() - const browser = { disconnect () {} } - const puppeteer = { - connect: sinon.stub().resolves(browser), - } - const handler = sinon.stub() - setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: handler }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) + const task = getTask() - await task({ name: 'test', args: ['arg1', 'arg2'] }) + await task({ name: testTask, args: ['arg1', 'arg2'] }) - expect(handler).to.be.calledWith(browser, 'arg1', 'arg2') + expect(testTaskHandler).to.be.calledWith(mockBrowser, 'arg1', 'arg2') }) it('disconnects the browser once the message handler is finished', async () => { - const on = sinon.stub() - const browser = { disconnect: sinon.stub() } - const puppeteer = { - connect: sinon.stub().resolves(browser), - } - const handler = sinon.stub() - setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: handler }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) + const task = getTask() - await task({ name: 'test', args: ['arg1', 'arg2'] }) + await task({ name: testTask, args: ['arg1', 'arg2'] }) - expect(browser.disconnect).to.be.called + expect(mockBrowser.disconnect).to.be.called }) it('returns the result of the handler', async () => { - const on = sinon.stub() - const browser = { disconnect: sinon.stub() } - const puppeteer = { - connect: sinon.stub().resolves(browser), - } - const handler = sinon.stub().resolves('result') + const resolution = 'result' + + onMessage[testTask].resolves(resolution) setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: handler }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) - const returnValue = await task({ name: 'test', args: ['arg1', 'arg2'] }) + const task = getTask() + const returnValue = await task({ name: testTask, args: ['arg1', 'arg2'] }) - expect(returnValue).to.equal('result') + expect(returnValue).to.equal(resolution) }) it('returns null if message handler returns undefined', async () => { - const on = sinon.stub() - const browser = { disconnect: sinon.stub() } - const puppeteer = { - connect: sinon.stub().resolves(browser), - } - const handler = sinon.stub().resolves(undefined) - + onMessage[testTask].resolves(undefined) setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: handler }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) - const returnValue = await task({ name: 'test', args: ['arg1', 'arg2'] }) + const task = getTask() + const returnValue = await task({ name: testTask, args: ['arg1', 'arg2'] }) expect(returnValue).to.be.null }) it('returns error object if debugger URL reference is lost', async () => { - const on = sinon.stub() - - setup({ on, onMessage: { - exists1: () => {}, - exists2: () => {}, - } }) + setup({ on, onMessage }) - const task = getTask(on) + const task = getTask() const returnValue = await task({ name: 'nonexistent', args: [] }) expect(returnValue.__error__).to.be.an('object') @@ -167,16 +170,11 @@ describe('#setup', () => { }) it('returns error object if browser is not supported', async () => { - const on = sinon.stub() + setup({ on, onMessage }) - setup({ on, onMessage: { - exists1: () => {}, - exists2: () => {}, - } }) + on.withArgs('after:browser:launch').yield({ family: 'Firefox' }, {}) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'Firefox' }, {}) - - const task = getTask(on) + const task = getTask() const returnValue = await task({ name: 'nonexistent', args: [] }) expect(returnValue.__error__).to.be.an('object') @@ -186,57 +184,43 @@ describe('#setup', () => { }) it('disconnects browser and returns error object if message handler errors', async () => { - const on = sinon.stub() - const browser = { disconnect: sinon.stub() } - const puppeteer = { - connect: sinon.stub().resolves(browser), - } - const handler = sinon.stub().rejects(new Error('handler error')) - + testTaskHandler.rejects(new Error('handler error')) setup({ on, - puppeteer: puppeteer as unknown as PuppeteerNode, - onMessage: { test: handler }, + puppeteer: mockPuppeteer as PuppeteerNode, + onMessage, }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) + simulateBrowserLaunch() - const task = getTask(on) - const returnValue = await task({ name: 'test', args: ['arg1', 'arg2'] }) + const task = getTask() + const returnValue = await task({ name: testTask, args: ['arg1', 'arg2'] }) - expect(browser.disconnect).to.be.called + expect(mockBrowser.disconnect).to.be.called expect(returnValue.__error__).to.be.an('object') expect(returnValue.__error__.message).to.equal('handler error') }) it('returns error object if message handler with given name cannot be found', async () => { - const on = sinon.stub() + setup({ on, onMessage }) - setup({ on, onMessage: { - exists1: () => {}, - exists2: () => {}, - } }) + simulateBrowserLaunch() - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) - - const task = getTask(on) + const task = getTask() const returnValue = await task({ name: 'nonexistent', args: [] }) expect(returnValue.__error__).to.be.an('object') expect(returnValue.__error__.message).to.equal( - 'Could not find message handler with the name `nonexistent`. Registered message handler names are: exists1, exists2.', + 'Could not find message handler with the name `nonexistent`. Registered message handler names are: test.', ) }) it('returns error object if message handler with given name cannot be found', async () => { - const on = sinon.stub() - // @ts-expect-error setup({ on, onMessage: { notAFunction: true } }) - on.withArgs('after:browser:launch').lastCall.args[1]({ family: 'chromium' }, { webSocketDebuggerUrl: 'ws://debugger' }) - - const task = getTask(on) + simulateBrowserLaunch() + const task = getTask() const returnValue = await task({ name: 'notAFunction', args: [] }) expect(returnValue.__error__).to.be.an('object') @@ -244,6 +228,52 @@ describe('#setup', () => { 'Message handlers must be functions, but the message handler for the name `notAFunction` was type `boolean`.', ) }) + + it('calls activateMainTab if there is a page in the browser', async () => { + (activateMainTabExport.activateMainTab as sinon.SinonStub).withArgs(mockBrowser).resolves() + setup({ on, onMessage, puppeteer: mockPuppeteer as PuppeteerNode }) + const task = getTask() + + simulateBrowserLaunch() + await task({ name: testTask, args: [] }) + + expect(activateMainTabExport.activateMainTab).to.be.calledWith(mockBrowser) + }) + + it('returns an error object if activateMainTab rejects', async () => { + (activateMainTabExport.activateMainTab as sinon.SinonStub).withArgs(mockBrowser).rejects() + + setup({ on, onMessage, puppeteer: mockPuppeteer as PuppeteerNode }) + simulateBrowserLaunch() + + const task = getTask() + + const returnValue = await task({ name: testTask, args: [] }) + + expect(returnValue.__error__).to.be.an('object') + expect(returnValue.__error__.message).to.equal( + 'Cannot communicate with the Cypress Chrome extension. Ensure the extension is enabled when using the Puppeteer plugin.', + ) + }) + + it('does not try to activate main tab when the browser is headless', async () => { + setup({ on, onMessage, puppeteer: mockPuppeteer as PuppeteerNode }) + on.withArgs('after:browser:launch').yield({ family: 'chromium', isHeaded: false }, { webSocketDebuggerUrl: 'ws://debugger' }) + const task = getTask() + + await task({ name: testTask, args: [] }) + + expect(activateMainTabExport.activateMainTab).not.to.be.called + }) + + it('does not try to activate main tab when the browser is electron', async () => { + setup({ on, onMessage, puppeteer: mockPuppeteer as PuppeteerNode }) + on.withArgs('after:browser:launch').yield({ family: 'chromium', isHeaded: true, name: 'electron' }, { webSocketDebuggerUrl: 'ws://debugger' }) + const task = getTask() + + await task({ name: testTask, args: [] }) + expect(activateMainTabExport.activateMainTab).not.to.be.called + }) }) describe('validation', () => { diff --git a/npm/puppeteer/tsconfig.json b/npm/puppeteer/tsconfig.json index fc6b5493beba..faec3a8532de 100644 --- a/npm/puppeteer/tsconfig.json +++ b/npm/puppeteer/tsconfig.json @@ -15,7 +15,8 @@ "target": "ES2020", "types": [ "cypress", - "./support" + "./support", + "./node_modules/@types/node" ] }, "include": [ diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 5c87e0d05ce6..c764a6291ea7 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -32,7 +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 { trackTopUrl } from '../util/trackTopUrl' import type { ICypress } from '../cypress' import type { ICookies } from './cookies' @@ -344,7 +344,10 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert return Cypress.backend('close:extra:targets') }) - handleTabActivation(Cypress) + if (!Cypress.isCrossOriginSpecBridge) { + trackTopUrl() + } + handleCrossOriginCookies(Cypress) } diff --git a/packages/driver/src/util/tab_activation.ts b/packages/driver/src/util/tab_activation.ts deleted file mode 100644 index f6f8bf0d87d5..000000000000 --- a/packages/driver/src/util/tab_activation.ts +++ /dev/null @@ -1,50 +0,0 @@ -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((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) -} diff --git a/packages/driver/src/util/trackTopUrl.ts b/packages/driver/src/util/trackTopUrl.ts new file mode 100644 index 000000000000..3eff1c1872e8 --- /dev/null +++ b/packages/driver/src/util/trackTopUrl.ts @@ -0,0 +1,21 @@ +// trackTopUrl sends messages to the extension about URL changes. The extension needs to +// keep track of these, so it can activate the main tab when the puppeteer plugin +// requests it. + +export const trackTopUrl = () => { + // track the initial url + window.postMessage({ + message: 'cypress:extension:url:changed', + url: window.location.href, + }) + + // and track every time location changes + window.addEventListener('popstate', (ev) => { + const url = window.location.href + + window.postMessage({ + message: 'cypress:extension:url:changed', + url, + }) + }) +} diff --git a/packages/extension/app/v3/content.js b/packages/extension/app/v3/content.js index f30ab8034bb3..8c34f18b27d3 100644 --- a/packages/extension/app/v3/content.js +++ b/packages/extension/app/v3/content.js @@ -14,10 +14,14 @@ 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 }) + port.postMessage({ message: 'activate:main:tab' }) + } + + // we need to keep track of the url that the main tab is on, so that we can + // activate the correct tab after a puppeteer task + if (data.message === 'cypress:extension:url:changed') { + port.postMessage({ message: 'url:changed', url: data.url }) } }) diff --git a/packages/extension/app/v3/manifest.json b/packages/extension/app/v3/manifest.json index 3460edc44b81..d2bd97c510c1 100644 --- a/packages/extension/app/v3/manifest.json +++ b/packages/extension/app/v3/manifest.json @@ -1,13 +1,9 @@ { "name": "Cypress", "description": "Adds theme and WebExtension APIs for testing with Cypress", - "applications": { - "gecko": { - "id": "automation-extension-v3@cypress.io" - } - }, "permissions": [ - "tabs" + "tabs", + "storage" ], "host_permissions": [ "http://*/*", @@ -20,7 +16,7 @@ "48": "icons/icon_48x48.png", "128": "icons/icon_128x128.png" }, - "browser_action": { + "action": { "default_title": "Cypress", "default_icon": { "19": "icons/icon_19x19.png", diff --git a/packages/extension/app/v3/service-worker.js b/packages/extension/app/v3/service-worker.js index 71374bdece7b..cbc47dc53618 100644 --- a/packages/extension/app/v3/service-worker.js +++ b/packages/extension/app/v3/service-worker.js @@ -10,8 +10,17 @@ // extension. sometimes that doesn't work and requires re-launching Chrome // and then reloading the extension via `chrome://extensions` -async function activateMainTab (url) { +async function getFromStorage (key) { + return new Promise((resolve) => { + chrome.storage.local.get(key, (storage) => { + resolve(storage[key]) + }) + }) +} + +async function activateMainTab () { try { + const url = await getFromStorage('mostRecentUrl') const tabs = await chrome.tabs.query({}) const cypressTab = tabs.find((tab) => tab.url.includes(url)) @@ -35,11 +44,15 @@ async function activateMainTab (url) { chrome.runtime.onConnect.addListener((port) => { port.onMessage.addListener(async ({ message, url }) => { if (message === 'activate:main:tab') { - await activateMainTab(url) + await activateMainTab() // send an ack back to let the content script know we successfully // activated the main tab port.postMessage({ message: 'main:tab:activated' }) } + + if (message === 'url:changed') { + chrome.storage.local.set({ mostRecentUrl: url }) + } }) }) diff --git a/packages/extension/test/integration/content_spec.js b/packages/extension/test/integration/content_spec.js deleted file mode 100644 index e949cffd3fd3..000000000000 --- a/packages/extension/test/integration/content_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -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 - }) - }) -}) diff --git a/packages/extension/test/integration/service-worker_spec.js b/packages/extension/test/integration/service-worker_spec.js deleted file mode 100644 index 6ea25e6150ea..000000000000 --- a/packages/extension/test/integration/service-worker_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -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) - }) -}) diff --git a/packages/extension/test/integration/background_spec.js b/packages/extension/test/integration/v2/background_spec.js similarity index 99% rename from packages/extension/test/integration/background_spec.js rename to packages/extension/test/integration/v2/background_spec.js index 1dd316c89198..9a54082ff9c7 100644 --- a/packages/extension/test/integration/background_spec.js +++ b/packages/extension/test/integration/v2/background_spec.js @@ -1,10 +1,10 @@ -require('../spec_helper') +require('../../spec_helper') const _ = require('lodash') const http = require('http') const socket = require('@packages/socket') const Promise = require('bluebird') const mockRequire = require('mock-require') -const client = require('../../app/v2/client') +const client = require('../../../app/v2/client') const browser = { cookies: { @@ -48,7 +48,7 @@ const browser = { mockRequire('webextension-polyfill', browser) -const background = require('../../app/v2/background') +const background = require('../../../app/v2/background') const { expect } = require('chai') const PORT = 12345 diff --git a/packages/extension/test/integration/v3/content_spec.js b/packages/extension/test/integration/v3/content_spec.js new file mode 100644 index 000000000000..8816a6c652d9 --- /dev/null +++ b/packages/extension/test/integration/v3/content_spec.js @@ -0,0 +1,108 @@ +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)', () => { + describe('on cypress:extension:activate:main:tab', () => { + const data = { message: 'cypress:extension:activate:main:tab' } + + it('posts message to port', () => { + window.addEventListener.yield({ data, source: window }) + + expect(port.postMessage).to.be.calledWith({ + message: 'activate:main:tab', + }) + }) + + it('is a noop if source is not the same window', () => { + window.addEventListener.yield({ data, source: {} }) + + expect(port.postMessage).not.to.be.called + }) + }) + + describe('on cypress:extension:url:changed', () => { + const data = { message: 'cypress:extension:url:changed', url: 'the://url' } + + it('posts message to port', () => { + window.addEventListener.yield({ data, source: window }) + + expect(port.postMessage).to.be.calledWith({ + message: 'url:changed', + url: data.url, + }) + }) + + it('is a noop if source is not the same window', () => { + window.addEventListener.yield({ data, source: {} }) + + expect(port.postMessage).not.to.be.called + }) + }) + + it('is a noop if message is not supported', () => { + 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)', () => { + describe('on main:tab:activated', () => { + 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 + }) + }) +}) diff --git a/packages/extension/test/integration/v3/service-worker_spec.js b/packages/extension/test/integration/v3/service-worker_spec.js new file mode 100644 index 000000000000..3591f4c0e899 --- /dev/null +++ b/packages/extension/test/integration/v3/service-worker_spec.js @@ -0,0 +1,137 @@ +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(), + }, + storage: { + local: { + set: sinon.stub(), + get: sinon.stub(), + }, + }, + } + + global.chrome = chrome + + require('../../../app/v3/service-worker') + }) + + beforeEach(() => { + chrome.tabs.query.reset() + chrome.tabs.update.reset() + chrome.storage.local.set.reset() + chrome.storage.local.get.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) + }) + + describe('on message', () => { + beforeEach(() => { + chrome.runtime.onConnect.addListener.yield(port) + }) + + describe('activate:main:tab', () => { + const tab1 = { id: '1', url: 'the://url' } + const tab2 = { id: '2', url: 'some://other.url' } + + beforeEach(() => { + chrome.tabs.query.resolves([tab1, tab2]) + }) + + describe('when there is a most recent url', () => { + beforeEach(() => { + chrome.storage.local.get.callsArgWith(1, { mostRecentUrl: tab1.url }) + }) + + it('activates the tab matching the url', async () => { + await port.onMessage.addListener.yield({ message: 'activate:main:tab' })[0] + + expect(chrome.tabs.update).to.be.calledWith(tab1.id, { active: true }) + }) + + describe('but no tab matches the most recent url', () => { + beforeEach(() => { + chrome.tabs.query.reset() + chrome.tabs.query.resolves([tab2]) + }) + + it('does not try to activate any tabs', async () => { + await port.onMessage.addListener.yield({ message: 'activate:main:tab' })[0] + expect(chrome.tabs.update).not.to.be.called + }) + }) + + describe('and chrome throws an error while activating the tab', () => { + let err + + beforeEach(() => { + sinon.stub(console, 'log') + err = new Error('uh oh') + chrome.tabs.update.rejects(err) + }) + + it('is a noop, logging the error', async () => { + await port.onMessage.addListener.yield({ message: 'activate:main:tab' })[0] + + // eslint-disable-next-line no-console + expect(console.log).to.be.calledWith('Activating main Cypress tab errored:', err) + }) + }) + }) + + describe('when there is not a most recent url', () => { + beforeEach(() => { + chrome.storage.local.get.callsArgWith(1, {}) + }) + + it('does not try to activate any tabs', async () => { + await port.onMessage.addListener.yield({ message: 'activate:main:tab' })[0] + expect(chrome.tabs.update).not.to.be.called + }) + }) + }) + + describe('url:changed', () => { + it('sets the mostRecentUrl', async () => { + const url = 'some://url' + + await port.onMessage.addListener.yield({ message: 'url:changed', url })[0] + expect(chrome.storage.local.set).to.be.calledWith({ mostRecentUrl: url }) + }) + }) + + it('is a noop if message is not a supported message', async () => { + await port.onMessage.addListener.yield({ message: 'unsupported' })[0] + + expect(chrome.tabs.update).not.to.be.called + expect(chrome.storage.local.set).not.to.be.called + }) + }) +})