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.
Three things have to be true:
tt_live_… token the MCP endpoint takes. A browser shared with you
to look at is not enough here — a Playwright client drives.
@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.
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.
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:
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.
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.
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.
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.
TABTUNNEL_URL | The origin, https. Plain http only to a loopback host, so a typo cannot send the token in the clear. |
TABTUNNEL_TOKEN | Your tt_live_… token. Sent as Authorization: Bearer; never printed. |
TABTUNNEL_BROWSER | Optional. The browser's id. Omitted, the one CDP-enabled browser you may drive. |
TABTUNNEL_FOREGROUND | Optional. 1 opens pages where the owner can watch. |
TABTUNNEL_TIMEOUT_MS | Optional. Discovery and connect budget. Default 30 000, above the browser-wake window. |
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:
newContext() works because Playwright needs it, but every context
is the same profile. Tests assuming isolation do not get it.window.open is a tab the extension did not open for you, so it
never reaches your client and no page event fires.expect timeouts generous.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.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_DISABLED | The switch is off for that browser, or the organization holds no CDP slot. The browser’s own page, behind Edit. |
NO_CDP_BROWSER | Your token sees no CDP-enabled browser. Enable one, or name one. |
AMBIGUOUS_BROWSER | Several are visible. Set ?browser=. |
READ_ONLY | Your grant on that browser is read-only, and a CDP client drives. |
BROWSER_OFFLINE | It is not checking in — Chrome closed, or the extension signed out. |
SESSION_CEILING | Your 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.