Docs

Playwright over CDP

Point Playwright at the real, signed-in Chrome of a person or a test machine. One package, two variables, and the rest is Playwright as you already write it.

Before you start

Three things have to be true:

  • A business organization with CDP slots. CDP is a paid per-browser feature; personal accounts do not have it. Slots are bought on the Billing page.
  • The switch on for that browser. An admin turns raw CDP on per machine from the Browsers page, spending one slot.
  • A token that may drive it. The same tt_live_… token the MCP endpoint takes. A browser shared with you to look at is not enough here — a Playwright client drives.

Quickstart

@tabtunnel/playwright is the client. It handles the discovery request, the bearer header on both round trips, and a @playwright/test fixture that connects once per worker instead of launching a local Chromium.

terminal
pnpm add -D @tabtunnel/playwright @playwright/test
# or: npm i -D @tabtunnel/playwright @playwright/test

Set two variables. The token is the one from the dashboard's Tokens page.

terminal
export TABTUNNEL_URL=https://tabtunnel.dev
export TABTUNNEL_TOKEN=tt_live_…

Then import test from here instead of from @playwright/test, and write the test you would have written anyway:

example.spec.ts
import { test, expect } from '@tabtunnel/playwright/test'

test('the dashboard loads', async ({ page }) => {
  await page.goto('https://app.example.com/')
  await expect(page.locator('h1')).toHaveText('Welcome back')
})

That is the whole of it. page, context, locators and expect are Playwright's own and behave as they always do — the browser fixture is the only one replaced, and it is worker-scoped, so a worker connects once and every test in it gets a fresh context and page on that connection.

Without the test runner

connect() hands you a Playwright Browser and asks for nothing else. Use it from a script, a job, or any runner that is not @playwright/test.

connect.ts
import { connect } from '@tabtunnel/playwright'

const browser = await connect()   // or connect({ url, token, browser, foreground, timeout })
const page = await browser.newPage()
await page.goto('https://example.com/')
console.log(await page.title())
await browser.close()             // disconnects; TabTunnel closes the tabs it opened

An explicit option wins over the environment variable of the same name.

Check it before you run anything

terminal
npx tabtunnel-playwright doctor

Reads the same variables, asks TabTunnel which browser a connection would drive, and prints either the answer or the reason it cannot. It opens no socket, so nothing is woken and no session is spent — which makes it the right first thing to run when a suite will not connect.

Variables it reads

TABTUNNEL_URLThe origin, https. Plain http only to a loopback host, so a typo cannot send the token in the clear.
TABTUNNEL_TOKENYour tt_live_… token. Sent as Authorization: Bearer; never printed.
TABTUNNEL_BROWSEROptional. The browser's id. Omitted, the one CDP-enabled browser you may drive.
TABTUNNEL_FOREGROUNDOptional. 1 opens pages where the owner can watch.
TABTUNNEL_TIMEOUT_MSOptional. Discovery and connect budget. Default 30 000, above the browser-wake window.

What this browser is not

It is one real profile, shared with the person whose browser it is, driven through an extension. The differences from a headless Chromium are the point of the product rather than gaps to be closed:

  • Contexts share cookies and storage. newContext() works because Playwright needs it, but every context is the same profile. Tests assuming isolation do not get it.
  • No video, no trace, no HAR. Those record from inside a browser Playwright launched. Screenshots work.
  • No per-context proxy or permissions. The browser's own network and settings are what there is.
  • Downloads land in the user's Downloads folder, as their Chrome is configured.
  • Popups are not seen. A window.open is a tab the extension did not open for you, so it never reaches your client and no page event fires.
  • Opening DevTools on a driven tab ends the session for it. Chrome allows one debugger, and the person at the keyboard wins.
  • Slower. Every command crosses two sockets and an extension — tens of milliseconds rather than one. Keep expect timeouts generous.
  • Few workers per browser. Two is a good number; eight is not.
  • Consent still applies. On confirm_every or allow_local, a goto to a site the owner has not allowed prompts them and the test waits. For a service browser set up for testing, allow_all is the mode to run under.

When it will not connect

Every one of these comes back as JSON with a code, so the message says what to do rather than which status it was.

CDP_DISABLEDThe switch is off for that browser, or the organization holds no CDP slot. The browser’s own page, behind Edit.
NO_CDP_BROWSERYour token sees no CDP-enabled browser. Enable one, or name one.
AMBIGUOUS_BROWSERSeveral are visible. Set ?browser=.
READ_ONLYYour grant on that browser is read-only, and a CDP client drives.
BROWSER_OFFLINEIt is not checking in — Chrome closed, or the extension signed out.
SESSION_CEILINGYour token already holds as many CDP sockets as it may. Fewer workers, or close a run that is still up.

A live connection is re-checked every ten seconds. If the grant is revoked, the browser paused, the token revoked or the CDP slot lapses while you are connected, the socket closes with a reason — revoked, paused, cdp_disabled, read_only — rather than running on. Sockets also close rather than silently dropping a message: slow_consumer and event_flood mean a page produced events faster than the limit allows, or faster than your client read them.

Is something here wrong, or missing? Tell us.