Multi-tab testing in Cypress: @cypress/puppeteer 1.0 is generally available

By Jennifer Shehane

Cypress logo with the headline Multi-tab testing in Cypress and the line @cypress/puppeteer 1.0 is now generally available, beside a drawing of a popup window whose button connects back to a

A receipt opens in a new tab. A sign-in or payment provider opens a popup, waits for the user to approve, and reports back to your app. A user signs out in one tab, and every other open tab has to follow. These flows are common in web apps, and they hit the same limit in a test: Cypress runs your test in a single tab, so a Cypress command can't reach the tab or popup your app just opened.

Since November 2023, the @cypress/puppeteer plugin has covered that gap as a public beta. It runs Puppeteer code (Puppeteer is a Node.js library for controlling Chromium-based browsers) against the same browser Cypress launched, then hands the result back to your Cypress test for assertions.

Today, @cypress/puppeteer 1.0.0 is generally available (GA), and a new Cypress multi-tab testing guide walks through setup, browser support, and worked examples for the most common multi-tab flows.

What changes with @cypress/puppeteer 1.0

Version 1.0.0 removes the beta label and makes the plugin's API a stable contract. setup(), retry(), and cy.puppeteer() work the same way they did in 0.1.x.

0.1.x (beta)

1.0.0 (GA)

Status

Public beta

Generally available

API stability

Any release could change the API

Breaking changes require a new major version

Bundled Puppeteer

puppeteer-core v21

puppeteer-core v24

Minimum Cypress version

13.6.0

14.0.0

Minimum Node.js version

Set by your Cypress version

18

Documentation

Plugin README

README plus the multi-tab testing guide on docs.cypress.io

The move to puppeteer-core v24 keeps the plugin compatible with current Chrome releases and picks up Puppeteer's more reliable handling of connections to an already running browser. Puppeteer v24 needs Node.js 18 or later and Cypress 14.0.0 or later.

The plugin itself works the way it always has. When you hook into the plugin, work in other tabs runs in Puppeteer, in Node.js, and the plugin passes the result back to your test.

Check whether your Cypress test needs a second tab

Before you add the plugin, ask what the test is really checking. Often it's only that your app opens the right page, and nothing passes between the tabs while both are open. You can test that from the Cypress tab, in every browser Cypress supports, with no plugin.

When the link itself is the behavior you care about, assert its attributes:

cy.contains('a', 'View receipt')
  .should('have.attr', 'target', '_blank')
  .and('have.attr', 'href', '/receipt.html')

To check the destination page too, remove the target attribute before clicking, so the browser opens the link in the Cypress tab:

cy.contains('a', 'View receipt').invoke('removeAttr', 'target').click()
cy.contains('h1', 'Receipt for order #1234')

When your app opens a tab from JavaScript, stub window.open before the page loads and assert how your app called it:

cy.visit('/connect.html', {
  onBeforeLoad(win) {
    cy.stub(win, 'open').as('open')
  },
})
cy.contains('button', 'Connect calendar').click()
cy.get('@open').should('have.been.calledWith', '/authorize.html')
Use @cypress/puppeteer when the test depends on what happens inside the other tab, or on the tabs talking to each other

Examples that should use the @cypress/puppeteer include a popup that reports back to your app, a change in one tab that has to show up in another, or two users interacting at once. The guide's section on testing new-tab links when the tabs don't interact has some full examples.

How @cypress/puppeteer connects Cypress to other tabs

The plugin has two halves.

  1. In your Cypress config, setup() registers named message handlers. Each handler runs in Node.js and receives a Puppeteer Browser connected to the browser Cypress launched.
  2. In your spec, cy.puppeteer('handlerName', ...args) runs that handler and yields whatever it returns, so you can chain Cypress assertions on the result.

Each time cy.puppeteer() runs, the plugin connects Puppeteer to the browser, calls your handler, and disconnects again. The browser stays open throughout. In headed Chromium-based browsers, the plugin also brings the Cypress tab back to the front before it disconnects, so your test continues where it left off.

How cy.puppeteer() reaches another tab. In the Cypress tab, the test calls cy.puppeteer(‘readNewTab’, ‘receipt’). That call goes to the readNewTab(browser, urlPart) message handler, which reads from the new tab or popup at receipt.html. The handler then returns the result to the Cypress tab, where the test asserts on it with .should(‘equal’, …).

Keep these behaviors in mind as you write handlers:

  • cy.puppeteer() runs your handler once and doesn't retry. When a handler depends on something that takes time, like a tab that's still opening, wait for it inside the handler with retry() or a Puppeteer method such as page.waitForSelector().
  • browser.pages() lists every tab and window, including the tab Cypress runs in. That tab's URL contains /__/, so skip it when you look for a tab by URL.
  • Each call gets a fresh connection. Look up pages inside the handler every time instead of keeping a Page around for the next call.
  • cy.puppeteer() runs on top of cy.task(), so arguments and return values must be serializable, and a handler that runs longer than taskTimeout (60 seconds by default) fails the command.
  • The Command Log shows one puppeteer entry for the whole handler, followed by any assertions you chain on its result.

Set up @cypress/puppeteer for multi-tab testing

This setup uses Cypress 14.0.0 or later and a Chromium-based browser. The example reads the heading of a receipt that an order page opens in a new tab.

Step 1: Install @cypress/puppeteer

The plugin bundles its own copy of puppeteer-core, so you don't need to install Puppeteer separately.

npm install --save-dev @cypress/puppeteer

Step 2: Import the support file

The support file adds the cy.puppeteer() command:

// cypress/support/e2e.js
import '@cypress/puppeteer/support'

Step 3: Register a message handler in your Cypress config

The readNewTab handler finds the new tab by its URL, reads its heading, and closes the tab so it doesn't carry over into the next test. retry() keeps calling the lookup until the tab has opened, for up to 4 seconds by default:

// cypress.config.ts
import { defineConfig } from 'cypress'
import { setup, retry } from '@cypress/puppeteer'

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      setup({
        on,
        onMessage: {
          async readNewTab(browser, urlPart: string) {
            // The tab can take a moment to open, so keep looking until it does
            const page = await retry(async () => {
              const pages = await browser.pages()
              const tab = pages.find((p) => p.url().includes(urlPart))

              if (!tab) throw new Error(`No tab with a URL containing "${urlPart}"`)

              return tab
            })

            const heading = await page.waitForSelector('h1')
            const text = await heading?.evaluate((el) => el.textContent)

            await page.close()

            return text
          },
        },
      })
    },
  },
})

Register all of your handlers in a single setup() call. A second call replaces the handlers from the first.

Step 4: Call the handler from your spec

The spec clicks the link, then asserts on the text the handler yields:

// cypress/e2e/order.cy.js
it('shows the receipt in a new tab', () => {
  cy.visit('/order.html')
  cy.contains('a', 'View receipt').click()

  cy.puppeteer('readNewTab', 'receipt').should('equal', 'Receipt for order #1234')
})

Step 5: Add the types for TypeScript projects

If you write your tests in TypeScript, add the plugin's support types to your tsconfig.json so your editor recognizes cy.puppeteer():

{
  "compilerOptions": {
    "types": ["cypress", "@cypress/puppeteer/support"]
  }
}

The setup section of the multi-tab testing guide includes install commands for every package manager.

Multi-tab workflows in the Cypress multi-tab testing guide

The receipt example is the simplest case. The guide has a worked example, with the app markup, the handler, and the spec, for each of these flows:

Workflow

Common examples

Read content from a tab that a link opens

Receipts, invoices, export previews

Complete a popup flow and assert in the main tab

Sign-in, payment, and account-linking popups

Open a second tab to test cross-tab updates

A theme or setting that syncs across tabs, signing out in every tab

Test two users at once with a separate browser context

Chat, comments, shared documents

Close leftover tabs between Cypress tests

An afterEach() hook that starts every test with one tab

Screenshot other tabs when a Cypress test fails

An image of every tab in a failed CI run

A separate browser context has its own cookies and storage, so it acts like a second person on another computer while still running in the browser Cypress launched. That's what makes the two-user example work: the Cypress tab stays signed in as one user while the handler signs in as another.

The guide also includes a troubleshooting section for @cypress/puppeteer errors, with the exact error messages you might see and what each one means.

Browser support for @cypress/puppeteer 1.0

The plugin works in Chromium-based browsers. What works also depends on whether the browser runs headless (cypress run) or headed (cypress open or cypress run --headed):

Browser

Headless

Headed

Open new tabs with browser.newPage()

Chrome for Testing, Chromium

Yes

Yes

Yes

Google Chrome 136 and earlier

Yes

Yes

Yes

Google Chrome 137 and later

Yes

No

Yes, headless only

Electron

Yes

Yes

No

Edge and other Chromium-based browsers

Yes

Only if the browser can load the Cypress extension

Yes

Firefox, WebKit

No

No

No

Two of those browsers have limits:

  • Google Chrome 137 and later can't run the plugin headed. In a headed browser, the plugin uses the Cypress extension to bring the Cypress tab back to the front, and Chrome 137 removed the --load-extension flag that loads it. With setup() in your Cypress config, headed Chrome 137 or later fails to launch, even for specs that never call cy.puppeteer(). For headed runs, use Chrome for Testing or Chromium. The Cypress Docker images repository has examples for Chrome for Testing and Chromium.
  • Electron works with tabs your app opens, but can't create new ones. browser.newPage() and browser.createBrowserContext() fail in Electron, Electron can't screenshot another tab, and a popup your app opens in Electron has no window.opener. Flows that need any of these run in Chrome, Chromium, or Edge.

Headless runs work in every supported browser, including any version of Google Chrome, so most CI setups need nothing extra beyond choosing a Chromium-based browser:

npx cypress run --browser chrome

If part of your suite runs in Firefox or WebKit, guard plugin calls with Cypress.browser.family === 'chromium' so the rest of the suite keeps running in those browsers.

Upgrade @cypress/puppeteer from 0.1.x to 1.0

The plugin's own API is the same in 1.0. What can change is the code inside your handlers, because each handler receives a Puppeteer Browser directly. Puppeteer's breaking changes between v21 and v24 apply to that code.

These are the Puppeteer changes most likely to show up in handlers:

Puppeteer v21

Puppeteer v24

page.waitForTimeout(ms)

Removed. Wait for a specific condition with page.waitForSelector() or page.waitForFunction().

page.$x() and page.waitForXPath()

Removed. Use the ::-p-xpath() selector, for example page.waitForSelector('::-p-xpath(//h2)').

browser.createIncognitoBrowserContext()

Renamed to browser.createBrowserContext().

puppeteer.devices

Removed. Import KnownDevices from puppeteer-core.

addEventListener() and removeEventListener() on Puppeteer objects

Removed. Use on() and off().

Screenshot and PDF methods typed as returning a Node.js Buffer

Typed as returning a Uint8Array. Wrap the result in Buffer.from() if you need Buffer methods.

HTTPRequest.url() and HTTPResponse.url() leave out the URL fragment

They include the fragment, such as #section.

To upgrade:

  1. If you're on Cypress 13, upgrade to Cypress 14.0.0 or later.
  2. Search your message handlers for the APIs in the table and update them.
  3. If you pass your own Puppeteer module to setup() with the puppeteer option, upgrade it to v24 too.
  4. Install the new version with npm install --save-dev @cypress/puppeteer@1.
  5. Run the specs that call cy.puppeteer().

The Upgrading to 1.0 section of the @cypress/puppeteer README links to the complete list of Puppeteer breaking changes for versions 22, 23, and 24.

Frequently asked questions about @cypress/puppeteer

Can Cypress test multiple tabs?

Cypress runs each test in a single tab. With the @cypress/puppeteer plugin, a test can read and control other tabs and popups in the same browser through Puppeteer, then assert on the results in Cypress.

Is @cypress/puppeteer free?

Yes. @cypress/puppeteer is an open source npm package under the MIT license that is supported by the Cypress team. It runs locally and in CI and doesn't need Cypress Cloud.

Do I need to install Puppeteer?

No. Version 1.0 bundles puppeteer-core v24. To use your own copy instead, pass it to setup() with the puppeteer option, and install a version that satisfies the plugin's puppeteer-core dependency.

Which versions of Cypress and Node.js does @cypress/puppeteer 1.0 support?

Version 1.0 requires Cypress 14.0.0 or later and Node.js 18 or later. Every Node.js version that Cypress 14 supports already meets the Node.js requirement. On Cypress 13, stay on @cypress/puppeteer@0.

Does @cypress/puppeteer work in Firefox or WebKit?

No. The plugin needs a Chromium-based browser, such as Chrome, Chrome for Testing, Chromium, Edge, or Electron. In Firefox or WebKit, cy.puppeteer() fails with an error.

Does @cypress/puppeteer work with Cypress Component Testing?

No. The plugin is for Cypress End-to-End Testing.

Does cy.puppeteer() retry like other Cypress commands?

No. It runs your handler once. Wait for slow conditions inside the handler with retry() or a Puppeteer method such as page.waitForSelector().

Does Test Replay show the other tabs?

No. Test Replay, Cypress screenshots, and videos record the Cypress tab. To keep a picture of the other tabs from a failed run, use the screenshot example in the multi-tab testing guide.