BrowserStack Cloud Grid Integration with Playwright
Running Playwright tests only on your laptop’s Chromium gives you fast feedback, but it tells you nothing about how your app behaves on Safari 17 on a real macOS Sonoma machine, or Chrome on Windows 11. The Playwright BrowserStack cloud grid lets you fan those same tests out across hundreds of real browser and OS combinations in parallel, without you maintaining a single VM. In this guide you’ll learn exactly how to connect Playwright and TypeScript to BrowserStack Automate, configure capabilities, run tests in parallel, mark pass/fail status, and avoid the integration mistakes that quietly break reporting.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why use a cloud grid with Playwright at all?
Playwright ships its own browser binaries, so locally you test against Chromium, Firefox, and WebKit. That covers the engines, but not the real-world matrix your users actually run: specific browser versions, real desktop operating systems, and device profiles. A cloud grid solves three problems at once.
- Coverage: Real Chrome, Edge, and Safari on real Windows and macOS, plus version pinning so you can reproduce a customer’s environment.
- Parallelism: Run dozens of test files at once across grid nodes instead of queuing them on a single machine.
- Zero infra: No Selenium Grid, no Docker images to patch, no flaky self-hosted nodes to babysit.
BrowserStack exposes Playwright support through a WebSocket CDP endpoint. Instead of launching a local browser, Playwright connects to a remote browser running in BrowserStack’s data center using browserType.connect(). Your test logic stays identical — only the connection layer changes.
How the BrowserStack connection works under the hood
The key Playwright API here is chromium.connect(), which takes a WebSocket endpoint URL. BrowserStack provides a CDP WebSocket at wss://cdp.browserstack.com/playwright. You pass your desired capabilities (browser, OS, version) as a URL-encoded JSON payload along with your username and access key. Once connected, you get a normal Playwright Browser object and everything downstream — contexts, pages, locators, assertions — works exactly as it does locally.
Here is a minimal standalone script that connects, runs a check, and disconnects. Store credentials in environment variables, never in source.
import { chromium, expect } from '@playwright/test';
const caps = {
browser: 'chrome',
browser_version: 'latest',
os: 'Windows',
os_version: '11',
name: 'Smoke: homepage loads',
build: 'playwright-build-1',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
};
(async () => {
const wsEndpoint =
`wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(
JSON.stringify(caps),
)}`;
const browser = await chromium.connect(wsEndpoint);
const page = await browser.newPage();
await page.goto('https://scrolltest.com/');
await expect(page).toHaveTitle(/Testing Academy/i);
await browser.close();
})();
Notice three things. The caps object carries both the environment (browser/os) and metadata (name, build) that show up in the BrowserStack dashboard. The endpoint is built by URL-encoding that JSON. And once connected, the rest of the script is plain Playwright — no proprietary API leaks into your test logic.
Wiring it into the Playwright Test runner
A raw script is fine for a smoke check, but real suites use @playwright/test with projects, fixtures, and parallel workers. The clean way to integrate is a custom fixture that overrides the built-in browser and page so your existing tests need zero changes. Each project in your config maps to one grid capability set.
First, the config. Each project carries its target browser/OS in use.connectOptions metadata that the fixture reads.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: 5,
reporter: [['list'], ['html', { open: 'never' }]],
projects: [
{
name: 'chrome@win11',
use: { bstack: { browser: 'chrome', os: 'Windows', os_version: '11' } },
},
{
name: 'safari@sonoma',
use: { bstack: { browser: 'playwright-webkit', os: 'OS X', os_version: 'Sonoma' } },
},
{
name: 'edge@win11',
use: { bstack: { browser: 'edge', os: 'Windows', os_version: '11' } },
},
],
});
Now the fixture. We extend Playwright’s test, declare a typed bstack option, and replace browser with a connected BrowserStack browser. The page fixture is inherited automatically, so it opens on the remote browser.
// fixtures/bstack.ts
import { test as base, chromium, type Browser } from '@playwright/test';
type BstackCaps = { browser: string; os: string; os_version: string };
export const test = base.extend<{ bstack: BstackCaps }, { browser: Browser }>({
bstack: [{ browser: 'chrome', os: 'Windows', os_version: '11' }, { option: true }],
browser: [
async ({ bstack }, use, workerInfo) => {
const caps = {
...bstack,
browser_version: 'latest',
name: workerInfo.project.name,
build: process.env.BUILD_NAME ?? 'local-build',
'client.playwrightVersion': '1.49.0',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
};
const wsEndpoint = `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(
JSON.stringify(caps),
)}`;
const browser = await chromium.connect(wsEndpoint);
await use(browser);
await browser.close();
},
{ scope: 'worker' },
],
});
export { expect } from '@playwright/test';
The { scope: 'worker' } option is important: it creates one BrowserStack session per worker rather than per test, which keeps your session count and cost predictable. Tests written against this fixture look completely ordinary.
// tests/login.spec.ts
import { test, expect } from '../fixtures/bstack';
test('user can sign in', async ({ page }) => {
await page.goto('https://scrolltest.com/login');
await page.getByLabel('Email').fill('qa@scrolltest.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Reporting pass/fail status back to BrowserStack
This is the step most teams forget, and it makes the dashboard useless. By default, a BrowserStack session is marked “completed” regardless of whether your assertions passed. To show real green/red status, you send a CDP command after the test finishes, reading Playwright’s testInfo.status. Add a hook in your fixture file or a setup that runs per test.
// fixtures/bstack.ts (afterEach hook)
import { test } from './bstack';
test.afterEach(async ({ page }, testInfo) => {
const passed = testInfo.status === testInfo.expectedStatus;
const payload = {
action: 'setSessionStatus',
arguments: {
status: passed ? 'passed' : 'failed',
reason: testInfo.error?.message ?? 'Test completed',
},
};
const cdpSession = await page.context().newCDPSession(page);
await cdpSession.send('Browserstack.executor' as any, payload as any);
});
Here page.context().newCDPSession(page) is a real Playwright API that opens a Chrome DevTools Protocol channel; BrowserStack listens for the custom Browserstack.executor command to update status, names, and other metadata. Comparing status against expectedStatus (instead of just checking 'passed') correctly handles tests you’ve marked with test.fail().
Local vs cloud grid: choosing per run
You rarely want every run hitting the cloud. Local execution is faster and free for day-to-day development; the grid is for cross-browser confidence in CI. A single environment flag lets the same fixture branch between local browsers and BrowserStack.
| Concern | Local Playwright | BrowserStack cloud grid |
|---|---|---|
| Browser engines | Chromium, Firefox, WebKit | Real Chrome, Edge, Safari, Firefox + versions |
| Real OS coverage | Your machine only | Windows, macOS, Linux variants |
| Parallel scale | Limited by CPU cores | Limited only by your plan’s session count |
| Startup latency | Milliseconds | A few seconds per session (remote connect) |
| Debugging | Headed mode, Inspector, traces | Dashboard video, logs, network, Playwright trace |
| Cost | Free | Per parallel session / minutes |
The branch logic lives in one place. When USE_BSTACK is unset, the fixture falls through to Playwright’s default local browser launch.
// fixtures/bstack.ts (hybrid browser fixture)
browser: [
async ({ bstack, playwright }, use, workerInfo) => {
if (!process.env.USE_BSTACK) {
const local = await playwright.chromium.launch();
await use(local);
await local.close();
return;
}
const caps = { ...bstack, name: workerInfo.project.name /* ...creds... */ };
const wsEndpoint = `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(
JSON.stringify(caps),
)}`;
const remote = await chromium.connect(wsEndpoint);
await use(remote);
await remote.close();
},
{ scope: 'worker' },
],
Run locally with npx playwright test and against the grid with USE_BSTACK=1 npx playwright test. Same tests, same assertions, two execution targets.
BrowserStack Local for testing private environments
If your app under test runs on localhost or behind a corporate firewall, the cloud browsers can’t reach it by default. BrowserStack Local opens a secure tunnel. Install browserstack-local and start it in a global setup, then add 'browserstack.local': 'true' to your capabilities so the remote browser routes traffic through the tunnel.
// global-setup.ts
import { Local } from 'browserstack-local';
const bsLocal = new Local();
export default function globalSetup(): Promise<void> {
return new Promise((resolve, reject) => {
bsLocal.start(
{ key: process.env.BROWSERSTACK_ACCESS_KEY, forceLocal: true },
(err?: Error) => (err ? reject(err) : resolve()),
);
});
}
export { bsLocal };
Reference this file via globalSetup in your config, and stop the tunnel in globalTeardown. With the tunnel up and browserstack.local enabled in caps, a cloud Safari session can hit http://localhost:3000 as if it were running beside your app.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Common integration mistakes to avoid
- Session per test instead of per worker: forgetting
scope: 'worker'spins up a new cloud session for every test, exhausting your parallel slots and inflating cost. - Never reporting status: without the
Browserstack.executorhook, every session shows as neutral and your dashboard can’t distinguish pass from fail. - Hardcoding credentials: keep
BROWSERSTACK_USERNAMEandBROWSERSTACK_ACCESS_KEYin environment variables or CI secrets, never inplaywright.config.ts. - Mismatched Playwright version: set
client.playwrightVersionin caps to match your installed version so BrowserStack provisions a compatible browser build. - Ignoring the local tunnel for staging: private URLs silently time out unless BrowserStack Local is running and
browserstack.localis true.
Putting it together in CI
In CI, pass a unique build name per pipeline run so sessions group correctly in the dashboard, and rely on Playwright’s own retry and trace settings for flake handling. A short GitHub Actions step looks like this.
// playwright.config.ts (CI-aware snippet)
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: { trace: 'on-first-retry' },
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
metadata: {
build: process.env.GITHUB_RUN_ID ?? `local-${Date.now()}`,
},
});
With retries and trace: 'on-first-retry', a failing cloud test reruns and captures a full Playwright trace you can open with npx playwright show-trace alongside the BrowserStack session video — giving you two complementary debugging views for free.
Conclusion
Integrating the Playwright BrowserStack cloud grid comes down to three moving parts: connect to the CDP WebSocket endpoint with encoded capabilities, wrap that connection in a worker-scoped fixture so your existing tests stay untouched, and report real pass/fail status back through the Browserstack.executor CDP command. Add a hybrid local/cloud switch and a BrowserStack Local tunnel for private environments, and you have a setup that runs the same suite on your laptop or across a hundred real browsers in CI. Start with a single smoke test against one capability set, confirm it appears correctly in the dashboard, then scale out your project matrix.
FAQ
Does Playwright work natively with BrowserStack, or do I need a plugin?
You don’t need a special plugin. Playwright connects to BrowserStack through its standard chromium.connect() API pointed at the wss://cdp.browserstack.com/playwright endpoint with URL-encoded capabilities. The browserstack-node-sdk can automate this wiring for you, but a thin custom fixture using only public Playwright APIs works just as well and keeps you in full control.
How do I mark a Playwright test as passed or failed on BrowserStack?
Open a CDP session with page.context().newCDPSession(page) in an afterEach hook and send the custom Browserstack.executor command with a setSessionStatus action. Derive the status by comparing Playwright’s testInfo.status to testInfo.expectedStatus so the dashboard reflects true pass/fail results.
Can I run tests on the cloud grid against my localhost app?
Yes, using BrowserStack Local. Start the tunnel with the browserstack-local package in your globalSetup, add 'browserstack.local': 'true' to your capabilities, and the remote browsers will route traffic through the secure tunnel to reach http://localhost or any firewalled staging URL.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
