---
title: "Multi-tab testing in Cypress: @cypress/puppeteer 1.0 is generally available"
url: "https://www.cypress.io/blog/cypress-puppeteer-ga"
description: "@cypress/puppeteer 1.0 is GA. Test new tabs, popups, and cross-tab flows in Cypress, check browser support, and upgrade from 0.1.x."
---

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

November 12, 2026

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 ](https://storage.ghost.io/c/12/89/12899ca1-4786-4eea-b7e1-9c5268baa48a/content/images/2026/11/cypress-puppeteer-ga-header-1.png)

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`](https://www.npmjs.com/package/@cypress/puppeteer?ref=cypress-io.ghost.io) plugin has covered that gap as a public beta. It runs [Puppeteer](https://pptr.dev/?ref=cypress-io.ghost.io) 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](https://docs.cypress.io/app/guides/multi-tab-testing?utm_source=blog&utm_medium=inline-link&utm_content=Cypress%20multi-tab%20testing%20guide&utm_campaign=cypress-puppeteer-ga) 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:

```js
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:

```js
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:

```js
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](https://docs.cypress.io/app/guides/multi-tab-testing?utm_source=blog&utm_medium=inline-link&utm_content=testing%20new-tab%20links%20when%20the%20tabs%20don%27t%20interact&utm_campaign=cypress-puppeteer-ga#Test-new-tab-links-when-the-tabs-dont-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`](https://pptr.dev/api/puppeteer.browser?ref=cypress-io.ghost.io) 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’, …).](https://storage.ghost.io/c/12/89/12899ca1-4786-4eea-b7e1-9c5268baa48a/content/images/2026/11/cy-puppeteer-how-it-works.png)

**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()`](https://on.cypress.io/task?utm_source=blog&utm_medium=inline-link&utm_content=cy.task\(\)&utm_campaign=cypress-puppeteer-ga), so arguments and return values must be serializable, and a handler that runs longer than [`taskTimeout`](https://on.cypress.io/configuration?utm_source=blog&utm_medium=inline-link&utm_content=taskTimeout&utm_campaign=cypress-puppeteer-ga#Timeouts) (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.

```bash
npm install --save-dev @cypress/puppeteer
```

### Step 2: Import the support file

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

```js
// 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:

```ts
// 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:

```js
// 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()`:

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

The [setup section of the multi-tab testing guide](https://docs.cypress.io/app/guides/multi-tab-testing?utm_source=blog&utm_medium=inline-link&utm_content=setup%20section%20of%20the%20multi-tab%20testing%20guide&utm_campaign=cypress-puppeteer-ga#Set-up-cypresspuppeteer-in-your-project) 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](https://docs.cypress.io/app/guides/multi-tab-testing?utm_source=blog&utm_medium=inline-link&utm_content=troubleshooting%20section%20for%20%40cypress%2Fpuppeteer%20errors&utm_campaign=cypress-puppeteer-ga#Troubleshoot-cypresspuppeteer-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](https://github.com/cypress-io/cypress-docker-images/tree/master/examples/chrome-for-testing?ref=cypress-io.ghost.io) and [Chromium](https://github.com/cypress-io/cypress-docker-images/tree/master/examples/chromium?ref=cypress-io.ghost.io).
-   **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:

```bash
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](https://github.com/cypress-io/cypress/tree/develop/npm/puppeteer?ref=cypress-io.ghost.io#upgrading-to-10) 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](https://docs.cypress.io/app/guides/multi-tab-testing?utm_source=blog&utm_medium=inline-link&utm_content=screenshot%20example%20in%20the%20multi-tab%20testing%20guide&utm_campaign=cypress-puppeteer-ga#Screenshot-other-tabs-when-a-Cypress-test-fails).