In Cypress, how does the @cypress/puppeteer plugin reach a tab the test opened?
answer
- most of it is not in your spec
- named handlers registered in the config
- a browser connected, not launched
- JSON crosses, objects do not
- wrap the page lookup in retry
basics
~20 sThe plugin registers named Node-side handlers in your Cypress config and adds a cy.puppeteer() command that calls one by name. The handler receives a Puppeteer browser connected to the browser Cypress launched, so it can find and drive the extra page.
solid answer
~40 sYou call the plugin's `setup({ on, onMessage })` inside `setupNodeEvents`, import `@cypress/puppeteer/support` in your support file, and then call `cy.puppeteer('name', ...args)` from a spec. `cy.puppeteer()` is a custom command that forwards the message over the same Node bridge `cy.task()` uses, so arguments and the returned value must be JSON-serializable. The handler runs in Node — no `cy` commands, no DOM APIs — and receives a Puppeteer `browser` connected to the browser Cypress already launched, which is why it is Chromium-family only. Inside it, `browser.pages()` is a one-shot snapshot, so wrap the page lookup in the plugin's `retry` helper (4000 ms total, 200 ms between tries) and `page.bringToFront()` before interacting, because Cypress keeps focus on its own tab. The handler's return value is what `cy.puppeteer()` yields, so assertions stay in the spec.
code
javascript · 31 lines// cypress.config.js
const { defineConfig } = require('cypress')
const { setup, retry } = require('@cypress/puppeteer')
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
setup({
on,
onMessage: {
async readViewerTitle(browser) {
const page = await retry(async () => {
const pages = await browser.pages()
const found = pages.find((p) => p.url().includes('/viewer/'))
if (!found) throw new Error('viewer tab not open yet')
return found
})
await page.bringToFront()
const heading = await page.waitForSelector('h1')
const title = await page.evaluate((el) => el.textContent, heading)
heading.dispose()
await page.close()
return title
},
},
})
},
},
})go deeper
Know that reaching a second tab needs a plugin rather than a Cypress command, and that the plugin code lives in the Cypress config, not in the spec.
Explain the two halves: a named handler running in Node against a connected Puppeteer browser, and a spec command that carries only JSON in and out.
Diagnose the flake: a one-shot page list against a tab that has not opened yet, missing bringToFront, and handlers that never close the pages they opened.
Own the decision to take on a beta, Chromium-only dependency, and set the rule that handlers stay small and factual while assertions stay in the spec.
## Two halves, and which one you are writing in `@cypress/puppeteer` is the official escape hatch from Cypress's one-tab ceiling, and it is still a public-beta, 0.x package rather than part of the core API. The thing to internalise is that **almost none of it runs in your spec**. - In `cypress.config.js` you call the plugin's `setup({ on, onMessage })` from `setupNodeEvents`. Each key of `onMessage` is a named handler that runs in Cypress's **Node** process. - In your support file you `import '@cypress/puppeteer/support'`, which registers a `cy.puppeteer()` custom command. - In the spec, `cy.puppeteer('readViewerTitle', ...args)` names one of those handlers. The command is a thin wrapper: it forwards the name and arguments across the same Node bridge that `cy.task()` uses, and yields whatever comes back. Two consequences follow immediately. Arguments and the return value must survive that crossing, so they must be **JSON-serializable** — you cannot hand a DOM element in or get a Puppeteer `Page` out. And a handler that returns nothing is normalised to `null`, because the underlying bridge refuses `undefined`. Inside the handler you receive a Puppeteer `browser` **connected to the browser Cypress launched**, not a fresh one. That is what makes the extra tab reachable at all, and it is why the plugin is Chromium-family only: the connection is a debugger attachment, and the plugin explicitly rejects any other browser family with an error. ## The wiring, in order 1. The plugin captures the running browser and its debugger endpoint when Cypress launches it. 2. `cy.puppeteer(name, ...args)` sends the message; the plugin connects Puppeteer to that endpoint, looks the handler up by name, and calls it with the browser. 3. The handler's resolved value is sent back and becomes what `cy.puppeteer()` yields, so your assertions stay in the spec where they belong. 4. Afterwards, on headed Chromium, the plugin brings Cypress's own tab back to the front through the Cypress browser extension, then disconnects. Step 4 is easy to miss and explains a class of confusing failures: if that extension is disabled, the plugin errors with a message about not being able to communicate with it. ## Why the naive handler flakes The single most common bug is treating the page list as if it were a Cypress query: ```js // flaky: pages() is a snapshot, taken once, immediately const page = (await browser.pages()).find((p) => p.url().includes('/viewer/')) await page.bringToFront() ``` Puppeteer's `browser.pages()` resolves once with the pages that exist at that instant. Your spec clicked the link a few milliseconds ago; the tab may not have opened, and if it has, its URL may still be `about:blank`. There is no retry-ability here — that is a property of Cypress queries, and you are not in Cypress. The plugin ships a `retry` helper for exactly this: - `retry(fn, { timeout, delayBetweenTries })` re-runs `fn` whenever it throws. - Defaults are a **4000 ms** total timeout and **200 ms** between tries. - The contract is inverted from what people expect: you *throw* to signal "not yet", and return the value to signal "found it". The other recurring surprises: - **Focus.** Cypress keeps its own tab in front, so call `page.bringToFront()` before interacting with the tab you found. - **Cleanup.** Dispose element handles and `page.close()` the tab you are done with, or the next test inherits it. - **Headed Chrome.** Chrome 137 and later dropped the extension-loading flag the plugin depends on, so headed use on Chrome-branded builds is refused outright with an explicit error; Chrome for Testing or Chromium are the headed options. ## What you give up crossing the bridge | in a Cypress command | in a Puppeteer handler | |---|---| | queries retry until `defaultCommandTimeout` | nothing retries unless you wrap it in `retry` | | every step appears in the Command Log with a DOM snapshot | one `puppeteer` entry with the message name | | failures point at the command that failed | failures arrive as a message thrown back into the spec | | assertions live next to the action | assertions live in the spec, on the returned value | This is the real cost, and it is why the plugin belongs at the edges of a suite. Keep handlers small and factual — find the page, read one value, close it — and put every assertion back in the spec, where a failure still reads like a Cypress failure.
- Why can't a `@cypress/puppeteer` handler return the Puppeteer `Page` it found?The value crosses Cypress's Node-to-browser bridge, which carries JSON only, so a live object with methods and open handles cannot survive the trip. Return the primitive you actually want to assert on — a title, a URL, a count — and keep every Puppeteer call on the Node side of the boundary.
- A handler works locally in `cypress open` on Chrome but fails on the CI runner. What would you check first?Browser family and headedness. The plugin supports Chromium-family browsers only, and headed Chrome 137 and later is refused outright because the extension-loading flag it relies on was removed. Chrome for Testing or Chromium in headed mode, or a plain headless `cypress run`, are the supported shapes.
saying these in an interview costs you the question
- Thinks the handler can call cy commands or touch the DOM
- Expects browser.pages() to retry like a Cypress query
- Believes the plugin launches its own separate browser
- Returns a Puppeteer Page object back to the spec
- Assumes it works in Firefox or WebKit runs