---
id: app/write-tests/multi-tab-testing
title: Multi-tab testing in Cypress
description: >-
  Test links, popups, and windows that open a new browser tab in Cypress with
  the @cypress/puppeteer plugin, or verify them without leaving the Cypress tab.
section: app
source_path: docs/app/write-tests/multi-tab-testing.mdx
version: 2e6ef7b8af1e3fd04641217acfd3a35cc208baef
updated_at: '2026-11-12T18:20:40.589Z'
---
# Multi-tab testing

Many user flows don't stay in one tab. A receipt opens in a new tab, a sign-in or payment provider opens a popup, and a settings change in one tab has to show up in another. Cypress runs your test in a single tab, and the [`@cypress/puppeteer`](https://github.com/cypress-io/cypress/tree/develop/npm/puppeteer) plugin reaches the tabs and popups outside it. The plugin runs [Puppeteer](https://pptr.dev/) code against the same browser Cypress launched, then hands the result back to your command chain, where you assert on it and see it in the Command Log like any other Cypress command.

This guide first helps you decide whether a test needs a second tab. It then covers how to set up the plugin and test the most common multi-tab flows.

## Test new-tab links when the tabs don't interact

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: no popup reports back to your app, and no change in one tab has to show up in the other. You can check that from the Cypress tab without opening a second one, and the test runs in every browser Cypress supports. When the test depends on what happens _inside_ the new tab, or on the tabs communicating, use [`@cypress/puppeteer`](#When-a-test-needs-cypresspuppeteer-and-a-second-tab) instead.

The first two examples run against this page:

order.html

```
<h1>Order #1234</h1>
<a href="/receipt.html" target="_blank">View receipt</a>
```

### Assert a new-tab link instead of following it

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

order.cy.js

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

### Open a new-tab link in the Cypress tab

To check the destination page as well, remove the `target` attribute before clicking. The browser then opens the link in the Cypress tab:

order.cy.js

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

### Stub `window.open` to check which page your app opens

When your app opens a tab from JavaScript, stub [`window.open`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open) before the page loads and assert how your app called it. This page opens an authorization popup from a button:

connect.html

```
<button id="connect">Connect calendar</button>
<script>
  document.querySelector('#connect').addEventListener('click', () => {
    window.open('/authorize.html', 'authorize', 'width=500,height=600')
  })
</script>
```

The spec stubs `window.open`, clicks the button, and checks the URL:

connect.cy.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')
```

[Stubs, spies, and clocks](/llm/markdown/app/guides/stubs-spies-and-clocks.md) covers `cy.stub()` in more depth. To follow a link to another origin in the same tab, use [`cy.origin()`](/llm/markdown/api/commands/origin.md).

## When a test needs @cypress/puppeteer and a second tab

Use `@cypress/puppeteer` when the test has to work inside the other tab, not only confirm that it opened. Each of these scenarios has a worked example later in this guide:

*   [Read content from a tab that a link opens](#Read-content-from-a-tab-that-a-link-opens), such as a receipt, an invoice, or an export preview
*   [Complete a popup flow](#Complete-a-popup-flow-and-assert-in-the-main-tab), such as a sign-in, payment, or account-linking popup that reports back to your app
*   [Test cross-tab updates](#Open-a-second-tab-to-test-cross-tab-updates), such as a setting or a sign-out in one tab that has to show up in the others
*   [Test two users at once](#Test-two-users-at-once-with-a-separate-browser-context), such as chat, comments, or a shared document

### How @cypress/puppeteer connects your spec to other tabs

The plugin has two halves:

*   In your Cypress config, you register named message handlers with `setup()`. Each handler runs in Node.js and receives a Puppeteer [`Browser`](https://pptr.dev/api/puppeteer.browser) connected to the browser Cypress launched.
*   In your spec, `cy.puppeteer('handlerName', ...args)` runs that handler and yields whatever it returns.

Here's one call from the first example below, `readNewTab`, which reads a tab that a link opened. The Cypress tab and the new tab both run in the browser Cypress launched, and the message handler between them runs in Node.js, in your Cypress config:

When your spec calls `cy.puppeteer('readNewTab', 'receipt')`, the call travels to Node.js through `cy.task()`, so its arguments must be serializable. The handler uses Puppeteer to find the new tab, read its heading, and close it. Its return value then travels back to the Cypress tab, where `.should()` asserts on it.

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 other than Electron, the plugin also brings the Cypress tab back to the front before it disconnects, so your test carries on where it left off.

Keep these behaviors in mind as you write handlers:

*   Puppeteer calls both tabs and windows [pages](https://pptr.dev/api/puppeteer.page). `browser.pages()` lists every one of them, including the tab Cypress runs in. That tab's URL contains `/__/` and your spec's file name, so skip it when you look for a tab by URL, or a handler can match the Cypress tab and close it.
*   Each call gets a fresh connection, so look up pages inside the handler every time. Don't keep a `Page` or element around for the next call.
*   `cy.puppeteer()` runs your handler once and doesn't retry. If an assertion chained after it fails, the test fails without running the handler again. Wait for anything that takes time, like a tab that's still opening, inside the handler with `retry()` or a Puppeteer method such as `page.waitForSelector()`.
*   `cy.puppeteer()` runs on top of [`cy.task()`](/llm/markdown/api/commands/task.md), so the same rules apply. Arguments and return values must be serializable, a handler that returns `undefined` yields `null`, and a handler that runs longer than [`taskTimeout`](/llm/markdown/app/references/configuration.md#Timeouts) (60 seconds by default) fails the command.
*   If a handler throws, `cy.puppeteer()` fails the test with the handler's error message.

## Browser support for @cypress/puppeteer

The plugin requires a Chromium-based browser, such as Chrome, Chrome for Testing, Chromium, Edge, or Electron. Two of those browsers have limits:

*   Google Chrome 137 and later can't run headed once you register the plugin. In a headed browser, the plugin needs 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()`. Headless runs work in any version of Chrome. 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) and [Chromium](https://github.com/cypress-io/cypress-docker-images/tree/master/examples/chromium).
*   Electron reads and controls tabs that your app opens, but it can't open a new tab with `browser.newPage()`, create a separate browser context, or take a screenshot of another tab. A popup your app opens in Electron also has no `window.opener`. The examples below point out where this matters.

## Set up @cypress/puppeteer in your project

Install the plugin. It bundles its own copy of Puppeteer, so you don't need to install Puppeteer separately.

*   npm
*   Yarn
*   pnpm
*   Bun

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

```
yarn add @cypress/puppeteer --dev
```

```
pnpm add --save-dev @cypress/puppeteer
```

```
bun add --dev @cypress/puppeteer
```

Import its support file to add the `cy.puppeteer()` command:

cypress/support/e2e.js

```
import '@cypress/puppeteer/support'
```

Then register your message handlers in your Cypress config with `setup()`. Each example below shows the handler it uses.

Register all of your handlers in a single `setup()` call. A second call replaces the handlers from the first, so when you combine examples from this guide, merge their `onMessage` objects into one.

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

tsconfig.json

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

## Read content from a tab that a link opens

Here, a link on an order page opens the receipt in a new tab:

order.html

```
<h1>Order #1234</h1>
<a href="/receipt.html" target="_blank">View receipt</a>
```

receipt.html

```
<h1>Receipt for order #1234</h1>
```

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. A tab takes a moment to open, so `retry()` keeps calling the lookup until it returns a page instead of throwing:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const { setup, retry } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async readNewTab(browser, urlPart) {
            // 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
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import { setup, retry } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      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
          },
        },
      })
    },
  },
})
```

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

order.cy.js

```
cy.visit('/order.html')
cy.contains('a', 'View receipt').click()

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

The Command Log shows the handler as a `puppeteer` step, followed by the assertion on the text it yielded:

`retry()` gives up after 4 seconds by default. For a page that takes longer to open, pass an options object as the second argument with a longer `timeout` in milliseconds, such as `{ timeout: 10000 }`.

## Complete a popup flow and assert in the main tab

Sign-in, payment, and account-linking flows often open a popup, wait for the user to approve something there, then update the original page. In this example, the popup reports back to your app with `window.opener.postMessage()`:

connect.html

```
<button id="connect">Connect calendar</button>
<p id="status">Not connected</p>
<script>
  document.querySelector('#connect').addEventListener('click', () => {
    window.open('/authorize.html', 'authorize', 'width=500,height=600')
  })

  window.addEventListener('message', (event) => {
    if (event.data === 'authorized') {
      document.querySelector('#status').textContent = 'Connected'
    }
  })
</script>
```

authorize.html

```
<button id="allow">Allow access</button>
<script>
  document.querySelector('#allow').addEventListener('click', () => {
    window.opener.postMessage('authorized', window.location.origin)
    window.close()
  })
</script>
```

The `allowAccessInPopup` handler waits for the popup, then clicks its `"Allow access"` button:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const { setup, retry } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async allowAccessInPopup(browser) {
            const popup = await retry(async () => {
              const pages = await browser.pages()
              const tab = pages.find((p) => p.url().includes('/authorize'))

              if (!tab) throw new Error('The authorization popup did not open')

              return tab
            })

            await popup.bringToFront()
            await popup.waitForSelector('button#allow')
            // The app closes the popup as soon as the button is clicked. Clicking
            // from inside the page returns before the popup closes, where
            // popup.click() can fail with "Target closed".
            await popup.$eval('button#allow', (button) => button.click())
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import { setup, retry } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async allowAccessInPopup(browser) {
            const popup = await retry(async () => {
              const pages = await browser.pages()
              const tab = pages.find((p) => p.url().includes('/authorize'))

              if (!tab) throw new Error('The authorization popup did not open')

              return tab
            })

            await popup.bringToFront()
            await popup.waitForSelector('button#allow')
            // The app closes the popup as soon as the button is clicked. Clicking
            // from inside the page returns before the popup closes, where
            // popup.click() can fail with "Target closed".
            await popup.$eval('button#allow', (button) => button.click())
          },
        },
      })
    },
  },
})
```

Back in the spec, Cypress retries the assertion on the main tab until the app updates:

connect.cy.js

```
it('connects a calendar through the authorization popup', () => {
  cy.visit('/connect.html')
  cy.contains('button', 'Connect calendar').click()

  cy.puppeteer('allowAccessInPopup')

  cy.get('#status').should('have.text', 'Connected')
})
```

Run this flow in Chrome, Chromium, or Edge. In Electron, a popup's `window.opener` is `null`, so the popup can't send its message back to your app.

## Open a second tab to test cross-tab updates

Some apps keep several tabs in sync, for example through `localStorage` and the [`storage` event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event). To test that, open the same page in a second tab and change something there. This settings page updates its theme whenever another tab changes it:

settings.html

```
<p>Theme: <span id="theme"></span></p>
<button id="use-dark">Use dark theme</button>
<script>
  const showTheme = () => {
    document.querySelector('#theme').textContent =
      localStorage.getItem('theme') || 'light'
  }

  document.querySelector('#use-dark').addEventListener('click', () => {
    localStorage.setItem('theme', 'dark')
    showTheme()
  })

  // Fires when another tab changes localStorage
  window.addEventListener('storage', showTheme)
  showTheme()
</script>
```

The `clickInNewTab` handler opens a URL in a new tab with `browser.newPage()`, clicks an element, and closes the tab. The new tab shares `localStorage` with the Cypress tab, the same as a tab a user opens on the same site:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const { setup } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async clickInNewTab(browser, url, selector) {
            const page = await browser.newPage()

            await page.goto(url)
            await page.click(selector)
            await page.close()
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import { setup } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async clickInNewTab(browser, url: string, selector: string) {
            const page = await browser.newPage()

            await page.goto(url)
            await page.click(selector)
            await page.close()
          },
        },
      })
    },
  },
})
```

The spec passes the current URL and a selector to the handler, then asserts that the original tab picked up the change:

settings.cy.js

```
it('updates the theme when another tab changes it', () => {
  cy.visit('/settings.html')
  cy.get('#theme').should('have.text', 'light')

  cy.url().then((url) => {
    cy.puppeteer('clickInNewTab', url, '#use-dark')
  })

  cy.get('#theme').should('have.text', 'dark')
})
```

`browser.newPage()` needs Chrome, Chromium, or Edge. In Electron it fails with `Protocol error (Target.createTarget): Not supported`.

### Sign out in one tab and check the other tabs

The same handler covers another common check: signing out in one tab signs the user out everywhere. This account page shows who is signed in and updates when another tab signs out:

account.html

```
<p id="session-status"></p>
<button id="sign-out">Sign out</button>
<script>
  const showSession = () => {
    const user = localStorage.getItem('user')
    document.querySelector('#session-status').textContent = user
      ? `Signed in as ${user}`
      : 'Signed out'
  }

  document.querySelector('#sign-out').addEventListener('click', () => {
    localStorage.removeItem('user')
    showSession()
  })

  // Fires when another tab signs out
  window.addEventListener('storage', showSession)
  showSession()
</script>
```

The spec signs in, uses `clickInNewTab` to click `"Sign out"` in a second tab, then asserts the Cypress tab signed out too:

account.cy.js

```
it('signs out every tab when one tab signs out', () => {
  cy.visit('/account.html', {
    onBeforeLoad(win) {
      win.localStorage.setItem('user', 'Ada')
    },
  })
  cy.get('#session-status').should('have.text', 'Signed in as Ada')

  cy.url().then((url) => {
    cy.puppeteer('clickInNewTab', url, '#sign-out')
  })

  cy.get('#session-status').should('have.text', 'Signed out')
})
```

## Test two users at once with a separate browser context

Chat, comments, and shared documents need a second person to test properly. A tab from `browser.newPage()` doesn't work for this, because it shares the Cypress tab's cookies, so it signs in as the same user. 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.

This chat page asks for a name, stores it in a cookie, and shows every message from the server:

chat.html

```
<form id="sign-in">
  <input id="name" aria-label="Name" />
  <button>Sign in</button>
</form>
<form id="send" hidden>
  <input id="message" aria-label="Message" />
  <button>Send</button>
</form>
<ul id="messages"></ul>
<script>
  const signIn = document.querySelector('#sign-in')
  const send = document.querySelector('#send')
  const currentUser = () => document.cookie.match(/user=([^;]+)/)?.[1]

  const showForms = () => {
    signIn.hidden = Boolean(currentUser())
    send.hidden = !currentUser()
  }

  const showMessages = async () => {
    const messages = await (await fetch('/api/messages')).json()
    document.querySelector('#messages').replaceChildren(
      ...messages.map(({ user, text }) => {
        const li = document.createElement('li')
        li.textContent = `${user}: ${text}`
        return li
      })
    )
  }

  signIn.addEventListener('submit', (event) => {
    event.preventDefault()
    document.cookie = `user=${document.querySelector('#name').value}; path=/`
    showForms()
  })

  send.addEventListener('submit', async (event) => {
    event.preventDefault()
    const input = document.querySelector('#message')
    await fetch('/api/messages', {
      method: 'POST',
      body: JSON.stringify({ user: currentUser(), text: input.value }),
    })
    input.value = ''
    showMessages()
  })

  showForms()
  showMessages()
  setInterval(showMessages, 500)
</script>
```

The `sendChatAsSecondUser` handler opens the chat in a new browser context, signs in under another name, sends a message, and waits until the message appears before closing the context:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const { setup } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async sendChatAsSecondUser(browser, url, name, text) {
            // A separate browser context has its own cookies and storage, like a
            // second person on another computer
            const context = await browser.createBrowserContext()
            const page = await context.newPage()

            await page.goto(url)
            await page.type('#name', name)
            await page.click('#sign-in button')
            await page.type('#message', text)
            await page.click('#send button')
            await page.waitForSelector(
              `#messages li::-p-text("${name}: ${text}")`
            )

            await context.close()
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import { setup } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async sendChatAsSecondUser(
            browser,
            url: string,
            name: string,
            text: string
          ) {
            // A separate browser context has its own cookies and storage, like a
            // second person on another computer
            const context = await browser.createBrowserContext()
            const page = await context.newPage()

            await page.goto(url)
            await page.type('#name', name)
            await page.click('#sign-in button')
            await page.type('#message', text)
            await page.click('#send button')
            await page.waitForSelector(
              `#messages li::-p-text("${name}: ${text}")`
            )

            await context.close()
          },
        },
      })
    },
  },
})
```

In the spec, the Cypress tab signs in as Ada, and the handler sends a message as Grace:

chat.cy.js

```
it('shows a message from another user', () => {
  cy.setCookie('user', 'Ada')
  cy.visit('/chat.html')

  cy.url().then((url) => {
    cy.puppeteer('sendChatAsSecondUser', url, 'Grace', 'Hi Ada!')
  })

  cy.contains('#messages li', 'Grace: Hi Ada!')
})
```

Ada stays signed in throughout, because Grace's cookie lives only in the separate context.

Separate browser contexts need Chrome, Chromium, or Edge. In Electron, `createBrowserContext()` fails with `Protocol error (Target.createBrowserContext): Failed to create browser context`.

## Close leftover tabs between Cypress tests

A tab that a test opens stays open until something closes it, and a leftover tab can confuse the next test's handlers. To start every test with only the Cypress tab, register a handler that closes all the others:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const { setup } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async closeExtraTabs(browser) {
            const pages = await browser.pages()
            // The Cypress tab's URL contains /__/, so leave that one open
            const extraTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )

            await Promise.all(extraTabs.map((page) => page.close()))
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import { setup } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      setup({
        on,
        onMessage: {
          async closeExtraTabs(browser) {
            const pages = await browser.pages()
            // The Cypress tab's URL contains /__/, so leave that one open
            const extraTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )

            await Promise.all(extraTabs.map((page) => page.close()))
          },
        },
      })
    },
  },
})
```

Then call it after every test from your support file. The plugin only works in Chromium-based browsers, so the hook checks the browser first, and the rest of your suite still runs in Firefox and WebKit:

cypress/support/e2e.js

```
import '@cypress/puppeteer/support'

afterEach(() => {
  if (Cypress.browser.family === 'chromium') {
    cy.puppeteer('closeExtraTabs')
  }
})
```

## Screenshot other tabs when a Cypress test fails

Cypress screenshots and videos record the Cypress tab only. When a multi-tab test fails in CI, a screenshot of the other tabs shows what each one looked like when the test failed. The `screenshotOtherTabs` handler saves one image per tab to your [`screenshotsFolder`](/llm/markdown/app/references/configuration.md#Screenshots) and yields the file paths. Register it in the same `setup()` call as `closeExtraTabs`:

*   cypress.config.js
*   cypress.config.ts

```
const { defineConfig } = require('cypress')
const fs = require('fs')
const path = require('path')
const { setup } = require('@cypress/puppeteer')

module.exports = defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      const screenshotsFolder =
        config.screenshotsFolder || 'cypress/screenshots'

      setup({
        on,
        onMessage: {
          async screenshotOtherTabs(browser, testName) {
            const pages = await browser.pages()
            const otherTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )
            const safeName = testName.replace(/[^\w -]/g, '')
            const files = []

            await fs.promises.mkdir(screenshotsFolder, { recursive: true })

            for (const [index, page] of otherTabs.entries()) {
              const file = path.join(
                screenshotsFolder,
                `${safeName} (tab ${index + 1}).png`
              )

              await page.screenshot({ path: file })
              files.push(file)
            }

            return files
          },
          async closeExtraTabs(browser) {
            const pages = await browser.pages()
            // The Cypress tab's URL contains /__/, so leave that one open
            const extraTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )

            await Promise.all(extraTabs.map((page) => page.close()))
          },
        },
      })
    },
  },
})
```

```
import { defineConfig } from 'cypress'
import fs from 'fs'
import path from 'path'
import { setup } from '@cypress/puppeteer'

export default defineConfig({
  // setupNodeEvents can be defined in either
  // the e2e or component configuration
  e2e: {
    setupNodeEvents(on, config) {
      const screenshotsFolder =
        config.screenshotsFolder || 'cypress/screenshots'

      setup({
        on,
        onMessage: {
          async screenshotOtherTabs(browser, testName: string) {
            const pages = await browser.pages()
            const otherTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )
            const safeName = testName.replace(/[^\w -]/g, '')
            const files: string[] = []

            await fs.promises.mkdir(screenshotsFolder, { recursive: true })

            for (const [index, page] of otherTabs.entries()) {
              const file = path.join(
                screenshotsFolder,
                `${safeName} (tab ${index + 1}).png`
              )

              await page.screenshot({ path: file })
              files.push(file)
            }

            return files
          },
          async closeExtraTabs(browser) {
            const pages = await browser.pages()
            // The Cypress tab's URL contains /__/, so leave that one open
            const extraTabs = pages.filter(
              (page) => !page.url().includes('/__/')
            )

            await Promise.all(extraTabs.map((page) => page.close()))
          },
        },
      })
    },
  },
})
```

Then take the screenshots from your support file when a test fails, before the tabs close:

cypress/support/e2e.js

```
import '@cypress/puppeteer/support'

afterEach(function () {
  // The plugin only works in Chromium-based browsers
  if (Cypress.browser.family !== 'chromium') return

  // Capture the other tabs before closing them, so a failed run in CI
  // shows what they looked like. Electron can't screenshot them.
  if (
    this.currentTest?.state === 'failed' &&
    Cypress.browser.name !== 'electron'
  ) {
    cy.puppeteer('screenshotOtherTabs', this.currentTest.fullTitle())
  }

  cy.puppeteer('closeExtraTabs')
})
```

The hook uses `function ()` rather than an arrow function so it can read `this.currentTest`. Upload your screenshots folder as a CI artifact, and a failed run keeps an image of every tab. The hook skips Electron because `page.screenshot()` never finishes there for a tab your app opened, which leaves the hook waiting until `taskTimeout`.

## Run multi-tab @cypress/puppeteer tests in CI

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

*   npm
*   Yarn
*   pnpm
*   Bun

```
npx cypress run --browser chrome
```

```
yarn cypress run --browser chrome
```

```
pnpm cypress run --browser chrome
```

```
bunx cypress run --browser chrome
```

Tests that open tabs or browser contexts themselves, take screenshots of other tabs, or rely on a popup's `window.opener` need Chrome, Chromium, or Edge rather than the default Electron browser. For headed runs in CI, use Chrome for Testing or Chromium, as described in [Browser support for @cypress/puppeteer](#Browser-support-for-cypresspuppeteer).

## Troubleshoot @cypress/puppeteer errors

### Could not find message handler with the name

The name you passed to `cy.puppeteer()` doesn't match a key in `onMessage`. The error lists the registered handlers. If a handler you registered is missing from that list, check for a second `setup()` call, which replaces the handlers from the first.

### Failed retrying after 4000ms in a message handler

`retry()` ran out of time before your lookup succeeded, and the error ends with the last message your function threw. In this spec, the link opens `receipt.html`, so `readNewTab` never finds a tab whose URL contains `"invoice"`:

order.cy.js

```
cy.visit('/order.html')
cy.contains('a', 'View receipt').click()

cy.puppeteer('readNewTab', 'invoice')
```

The Command Log marks the `puppeteer` step as failed and shows the handler's error:

Check that the tab opened and that its URL contains what you're matching on. If it's slow to open, pass a longer `timeout` to `retry()`.

### cy.puppeteer() times out waiting for a handler

The error says that `cy.task('__cypressPuppeteer__')` timed out, because `cy.puppeteer()` runs on `cy.task()`. A handler ran longer than [`taskTimeout`](/llm/markdown/app/references/configuration.md#Timeouts). Look for a `waitForSelector()` or navigation that never finishes, or a `page.screenshot()` in Electron, which never finishes for a tab your app opened. Otherwise, raise `taskTimeout` for tests with slow handlers.

### Protocol errors from Target.createTarget or createBrowserContext

`Protocol error (Target.createTarget): Not supported` and `Protocol error (Target.createBrowserContext): Failed to create browser context` mean the handler called `browser.newPage()` or `browser.createBrowserContext()` in Electron, which supports neither. Run the spec in Chrome, Chromium, or Edge.

### Only browsers in the Chromium family are supported

The spec ran in Firefox or WebKit. Switch to a Chromium-based browser.

### @cypress/puppeteer does not work in Google Chrome v137 and higher

You ran headed Google Chrome 137 or later, which can't load the Cypress extension. Use Chrome for Testing, Chromium, or Electron for headed runs, or run Chrome headlessly with `cypress run`.

### Cannot communicate with the Cypress Chrome extension

The plugin couldn't bring the Cypress tab back to the front because the Cypress extension didn't respond. Check that the extension is enabled at `chrome://extensions/` in the browser Cypress launched, and that your organization's browser policy allows it ([Allow the Cypress Chrome extension](/llm/markdown/app/references/troubleshooting.md#Allow-the-Cypress-Chrome-extension)).

### Lost the reference to the browser after a config reload

Your Cypress config reloaded without the browser relaunching. Close the browser and open it again from Cypress.

## See also

*   [`cy.origin()`](/llm/markdown/api/commands/origin.md)
*   [`cy.stub()`](/llm/markdown/api/commands/stub.md)
*   [`cy.task()`](/llm/markdown/api/commands/task.md)
*   [Cross origin testing](/llm/markdown/app/guides/cross-origin-testing.md)
*   [Plugins guide](/llm/markdown/app/plugins/plugins-guide.md)
*   [Trade-offs](/llm/markdown/app/references/trade-offs.md)
