Playwright Advanced Topics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Most of the Playwright documentation describes one setup: a test runner launches a browser it owns, opens a page in a fresh context and drives it. This page covers everything around the edges of that setup: browsers that already exist and have to be attached to (a WebView2 control, a debugging port, another Playwright process, a Selenium Grid node), host applications that are not a normal browser (an Electron app, an Android device), a browser that has an extension loaded, the page.screencast API for recording and annotating what the browser shows, and custom selector engines. It is written for Playwright 1.64.1 (Node.js) and was checked on 25 October 2026. The Python, Java and .NET bindings expose the same ideas under their own naming (examples there were run at 1.63, see Playwright with Python, Java and .NET); every example below is TypeScript or JavaScript. The examples that could be run on the test machine (macOS, headless Chromium 156, Electron 45, a Selenium Grid 4.51 in Docker) were run against the Bookshelf application; the WebView2 and Android examples were not run, for the reasons given in their sections.

Locators, fixtures and projects are explained in Locators, Actions & Auto-waiting and Configuration, Fixtures & Projects; the trace and video features that the screencast API builds on are covered in Screenshots, Videos & Traces.

Choosing a way to attach to a browser

Four different mechanisms put Playwright in front of a browser it did not start itself, and they are easy to confuse.

API Protocol and what it needs Typical use

browserType.launchServer() and browserType.connect(wsEndpoint)

Playwright’s own protocol over a web socket. Client and server must run the same Playwright minor version.

Run the browser in one process or container and the tests in another. Full Playwright feature set.

browser.bind() and browserType.connect()

The same Playwright protocol, but opened on a browser that a script has already launched (Playwright 1.59).

Let a test, playwright-cli or @playwright/mcp share one live browser.

browserType.connectOverCDP(endpoint)

The Chrome DevTools Protocol of a Chromium-based browser. Only Chromium; fewer features than a Playwright launch.

Attach to WebView2, to Electron’s renderer, to a browser started with --remote-debugging-port, or to a cloud browser.

SELENIUM_REMOTE_URL

Playwright asks a Selenium Grid 4 for a session and then uses the CDP web socket that the grid exposes.

Reuse an existing Selenium Grid for Chrome or Edge without changing test code. Experimental.

The sections below take them one at a time, after the extension, Electron and Android topics, which launch a browser differently from the default.

Chrome extensions

Extensions are the one thing a normal Playwright launch cannot do: they need a persistent profile, a Chromium that still accepts the side-loading flags, and a browser mode that runs extension code. The conditions that Playwright documents are:

  • Extensions only work in Chromium and only when the browser is started with a persistent context (chromium.launchPersistentContext(userDataDir, options)). A plain browser.newContext() cannot load one.

  • Google Chrome and Microsoft Edge removed the command-line flags (--load-extension) that side-load an extension, so use the Chromium build that ships with Playwright.

  • Pass both --disable-extensions-except=<path> and --load-extension=<path> in args. Several extensions are given as a comma-separated list.

  • To run in headless mode use channel: 'chromium'. That selects the "new headless" mode (Playwright 1.49), which is the real Chromium rendering path and supports extensions. Without the channel, headless: true uses the stripped headless shell binary, which loads no extensions at all. Alternatively launch headed (headless: false).

  • Manifest V2 extensions are no longer supported (Playwright 1.55); write the extension as Manifest V3.

  • The browser flags are passed "at your own risk": some arguments can break Playwright itself.

A small extension to test

The extension used for the examples is a Manifest V3 extension with a content script that adds a banner to every Bookshelf page, a background service worker that counts how many banners were shown, and a popup page.

{
  "manifest_version": 3,
  "name": "Bookshelf Banner",
  "version": "1.0.0",
  "description": "Adds a banner to Bookshelf pages.",
  "background": { "service_worker": "background.js" },
  "content_scripts": [
    {
      "matches": ["http://localhost/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "action": { "default_popup": "popup.html" },
  "permissions": ["storage"]
}

Source: Playwright — Chrome extensions (the extension itself is a minimal Manifest V3 sample written for these examples)

// content.js
const banner = document.createElement('div');
banner.setAttribute('data-testid', 'ext-banner');
banner.textContent = 'Extension active';
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;padding:4px;background:#fde68a;color:#000;text-align:center;z-index:99999';
document.body.appendChild(banner);
chrome.runtime.sendMessage({ type: 'banner-shown', url: location.href });

// background.js (the service worker)
let shown = 0;

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === 'banner-shown') {
    shown += 1;
    sendResponse({ ok: true });
  }
  if (message.type === 'count')
    sendResponse({ shown });
});

globalThis.getShownCount = () => shown;

Loading the extension in a fixture

The recommended pattern is to override the built-in context fixture with a persistent context, so that every page in the tests belongs to a browser that has the extension. The extensionId fixture derives the extension’s id from the URL of its service worker (chrome-extension://<id>/background.js), which is needed to open the extension’s own pages. Pass an empty string as userDataDir to get a throw-away temporary profile that Playwright deletes on close.

import { test as base, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';

export const test = base.extend<{
  context: BrowserContext;
  extensionId: string;
}>({
  context: async ({}, use) => {
    const pathToExtension = path.join(__dirname, 'ext');
    const context = await chromium.launchPersistentContext('', {
      channel: 'chromium',
      args: [
        `--disable-extensions-except=${pathToExtension}`,
        `--load-extension=${pathToExtension}`,
      ],
    });
    await use(context);
    await context.close();
  },
  extensionId: async ({ context }, use) => {
    let [serviceWorker] = context.serviceWorkers();
    if (!serviceWorker)
      serviceWorker = await context.waitForEvent('serviceworker');
    await use(serviceWorker.url().split('/')[2]);
  },
});

export const expect = test.expect;

Testing a content script, the popup and the service worker

The three tests exercise the three kinds of extension code. The content script is tested through the page it modifies, the popup by navigating to its chrome-extension:// URL, and the service worker through the Worker object returned by context.serviceWorkers() (or the serviceworker event), whose evaluate() runs code inside the worker where the chrome.* APIs are available.

import { test, expect } from './fixtures';

test('the content script adds a banner to Bookshelf', async ({ page }) => {
  await page.goto('/books');
  await expect(page.getByTestId('ext-banner')).toHaveText('Extension active');
});

test('the popup page is served from chrome-extension://', async ({ page, extensionId }) => {
  await page.goto(`chrome-extension://${extensionId}/popup.html`);
  await expect(page.locator('#status')).toHaveText('Bookshelf Banner popup');
});

test('the service worker counts the banners it was told about', async ({ context, page }) => {
  const [worker] = context.serviceWorkers();
  expect(worker.url()).toMatch(/^chrome-extension:\/\/[a-p]{32}\/background\.js$/);

  await page.goto('/books');
  await expect(page.getByTestId('ext-banner')).toBeVisible();

  const manifestName = await worker.evaluate(() => chrome.runtime.getManifest().name);
  expect(manifestName).toBe('Bookshelf Banner');

  await expect.poll(() => worker.evaluate(() => (globalThis as any).getShownCount())).toBeGreaterThan(0);
});

These three tests passed in headless Chromium (3 passed). The configuration for the project needs no special settings (no browserName or device is required, because the fixture launches the browser itself); the only constraint is that tests that load an extension cannot share a browser, so each test launches its own, which costs about a second.

Headless shell versus the chromium channel

The reason for channel: 'chromium' is easy to demonstrate. The same extension, loaded with the same arguments, in the default headless mode and with the chromium channel:

import { chromium } from '@playwright/test';
import path from 'path';

const ext = path.join(import.meta.dirname, 'ext');
for (const channel of [undefined, 'chromium']) {
  const context = await chromium.launchPersistentContext('', {
    channel,
    args: [`--disable-extensions-except=${ext}`, `--load-extension=${ext}`],
  });
  const page = await context.newPage();
  await page.goto(process.env.BASE_URL + '/books');
  await page.waitForTimeout(1500);
  console.log(`channel=${channel ?? 'default (headless shell)'}: serviceWorkers=${context.serviceWorkers().length}, banner=${await page.getByTestId('ext-banner').count()}`);
  await context.close();
}
channel=default (headless shell): serviceWorkers=0, banner=0
channel=chromium: serviceWorkers=1, banner=1

Service worker suspension

Chrome suspends a Manifest V3 service worker after roughly 30 seconds of inactivity and restarts it on demand. Playwright keeps the same Worker handle across a restart and does not emit a new serviceworker event, so a handle obtained once stays usable: an evaluate() call issued while the worker restarts waits until the new context is ready. A call that is already in flight at the moment of suspension fails with a "Service worker restarted" error, the same way a navigation in the middle of a call does. This was documented from the official page and not provoked in the examples above (they finish well within the idle window).

Other practical points:

  • chrome.* extension APIs are not available in an ordinary page, only inside the worker, the popup and other extension pages.

  • The extension id is derived from the extension path in unpacked mode, but reading it from the service worker URL (as the fixture does) is more robust than hard-coding it.

  • To look at a Bookshelf page that the extension changed in a screenshot, the banner is part of the page DOM; popup pages are ordinary pages opened in the same context and can be screenshotted like any other.

  • In CI run on Linux with channel: 'chromium'; headed mode needs xvfb-run.

Microsoft Edge WebView2

WebView2 embeds Microsoft Edge in a Windows application (WinForms, WPF, WinUI). Playwright automates it through the Chrome DevTools Protocol, using browserType.connectOverCDP(). WebView2 exists on Windows 10 and 11 only, so this topic can only be tested on Windows, and none of the code in this section was run on the macOS machine used for the other examples.

To make the control listen for CDP connections, start it with --remote-debugging-port=<port> either through the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS environment variable or, in code, as AdditionalBrowserArguments when creating the CoreWebView2Environment:

await this.webView.EnsureCoreWebView2Async(await CoreWebView2Environment.CreateAsync(null, null, new CoreWebView2EnvironmentOptions()
{
  AdditionalBrowserArguments = "--remote-debugging-port=9222",
})).ConfigureAwait(false);

Once the application runs, attach and take the page from the default context:

const browser = await playwright.chromium.connectOverCDP('http://localhost:9222');
const context = browser.contexts()[0];
const page = context.pages()[0];

A WebView2 control uses one user data directory for all instances by default, so tests that run in parallel interfere with each other. Give every worker its own directory with WEBVIEW2_USER_DATA_FOLDER and its own CDP port. The fixture below starts the application as a sub-process per worker, waits for it to print WebView2 initialized (written from the CoreWebView2InitializationCompleted event) and exposes its page as a normal page fixture.

import { test as base } from '@playwright/test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import childProcess from 'child_process';

const EXECUTABLE_PATH = path.join(
    __dirname,
    '../../webview2-app/bin/Debug/net8.0-windows/webview2.exe',
);

export const test = base.extend({
  browser: async ({ playwright }, use, testInfo) => {
    const cdpPort = 10000 + testInfo.workerIndex;
    // Make sure that the executable exists and is executable
    fs.accessSync(EXECUTABLE_PATH, fs.constants.X_OK);
    const userDataDir = path.join(
        fs.realpathSync.native(os.tmpdir()),
        `playwright-webview2-tests/user-data-dir-${testInfo.workerIndex}`,
    );
    const webView2Process = childProcess.spawn(EXECUTABLE_PATH, [], {
      shell: true,
      env: {
        ...process.env,
        WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${cdpPort}`,
        WEBVIEW2_USER_DATA_FOLDER: userDataDir,
      }
    });
    await new Promise<void>(resolve => webView2Process.stdout.on('data', data => {
      if (data.toString().includes('WebView2 initialized'))
        resolve();
    }));
    const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
    await use(browser);
    await browser.close();
    childProcess.execSync(`taskkill /pid ${webView2Process.pid} /T /F`);
    fs.rmdirSync(userDataDir, { recursive: true });
  },
  context: async ({ browser }, use) => {
    const context = browser.contexts()[0];
    await use(context);
  },
  page: async ({ context }, use) => {
    const page = context.pages()[0];
    await use(page);
  },
});

export { expect } from '@playwright/test';
import { test, expect } from './webView2Test';

test('test WebView2', async ({ page }) => {
  await page.goto('https://playwright.dev');
  const getStarted = page.getByText('Get Started');
  await expect(getStarted).toBeVisible();
});

This example was not run: it needs Windows, a built WebView2 application and the WebView2 runtime. For debugging, the control’s context menu has "Inspect" (or press F12), and CoreWebView2.OpenDevToolsWindow opens DevTools from code. A local Chrome DevTools session can also connect to the same port. Because the connection is plain CDP, the same recipe attaches to any Chromium-based host that exposes a debugging port, such as an Electron renderer or a kiosk browser (see connectOverCDP).

Electron (experimental)

Playwright can launch and control an Electron application through the _electron namespace. The leading underscore marks the API as experimental: it can change between releases. Supported Electron versions are v21 and later. The electron package must be installed in the project (Playwright launches the executable that node_modules/.bin/electron points at, or the one given in executablePath).

electron.launch(options) starts the application and returns an ElectronApplication, which gives two things:

  • electronApp.evaluate(fn) runs a function in the main Electron process. Its first argument is whatever require('electron') returns there (app, dialog, BrowserWindow), so it can read application state and stub native APIs.

  • electronApp.firstWindow(), electronApp.windows() and the window event give ordinary Playwright Page objects for each BrowserWindow, so locators, assertions, screenshots and tracing work as in a browser.

The most useful launch options:

Option Meaning

args

Arguments passed to the application, normally the main script (['main.js']).

executablePath

A packaged application to launch instead of the default Electron executable.

cwd, env

Working directory and environment of the process (the environment defaults to process.env).

timeout

How long to wait for the application to start; 30 seconds by default, 0 disables it.

recordVideo, recordHar

Video and HAR recording for all windows. recordVideo accepts size, fps and a showActions object with the same styling options as the screencast annotations below.

tracesDir, artifactsDir

Where traces and other artifacts are written; artifactsDir was added in 1.59.

colorScheme, locale, timezoneId, geolocation, offline, extraHTTPHeaders, httpCredentials, bypassCSP, ignoreHTTPSErrors

The usual context options, applied to every window.

chromiumSandbox

Enables Chromium sandboxing, off by default (1.59).

If a launch ends in a timeout, check that the nodeCliInspect fuse (FuseV1Options.EnableNodeCliInspectArguments) of the application has not been disabled, because Playwright attaches through the Node inspector.

A minimal Electron application under test

To exercise the API without an application of its own, the example uses a tiny Electron main script that opens the Bookshelf catalogue in a hidden window:

const { app, BrowserWindow } = require('electron');

app.whenReady().then(() => {
  const win = new BrowserWindow({ width: 1100, height: 800, show: false });
  win.loadURL((process.env.BASE_URL ?? 'http://localhost:3000') + '/books');
});

// Quit when the last window closes, also on macOS.
app.on('window-all-closed', () => app.quit());

The driver script launches it, reads state from the main process, drives the window, and stubs a native dialog in the main process (Playwright cannot intercept native dialogs, because those calls go straight to the operating system, so the documented technique is to replace the dialog methods through electronApp.evaluate). The replacement stays in place until the application closes; the synchronous variants (showOpenDialogSync, showSaveDialogSync, showMessageBoxSync) are stubbed the same way, returning the value directly instead of a promise.

import { _electron as electron } from 'playwright-core';
import { strict as assert } from 'node:assert';

const app = await electron.launch({
  args: ['main.js'],
  env: { ...process.env, BASE_URL: process.env.BASE_URL },
});

// Runs in the Electron main process.
const version = await app.evaluate(({ app }) => app.getVersion());
console.log('app version from main process:', version);
console.log('electron version:', await app.evaluate(() => process.versions.electron));

const window = await app.firstWindow();
await window.waitForLoadState('domcontentloaded');
console.log('title:', await window.title());

// Same Page API as in the browser.
await window.getByTestId('search-input').fill('quiet');
await window.getByRole('button', { name: 'Search' }).click();
const cards = window.getByTestId('book-card');
await cards.first().waitFor();
assert.equal(await cards.count(), 1);
console.log('heading:', await window.getByRole('heading', { name: 'The Quiet Compiler' }).textContent());

// Stub a native dialog in the main process.
await app.evaluate(({ dialog }) => {
  dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: ['/tmp/x.txt'] });
});
const result = await app.evaluate(({ dialog }) => dialog.showOpenDialog({}));
console.log('stubbed dialog result:', JSON.stringify(result));

await window.screenshot({ path: 'electron.png' });
await app.close();
console.log('closed');
app version from main process: 45.0.0
electron version: 45.0.0
title: Catalogue - Bookshelf
heading: The Quiet Compiler
stubbed dialog result: {"canceled":false,"filePaths":["/tmp/x.txt"]}
closed

This was run with Electron 45.0.0 installed in its own directory, next to playwright-core 1.64.1. Electron’s install script is blocked by npm 11’s default lifecycle-script policy, so the download of the Electron binary was started explicitly with node node_modules/electron/install.js. The same script works in a project that uses @playwright/test: import _electron from @playwright/test and wrap the application in a worker or test fixture so that it is closed even when the test fails.

A few Electron-specific notes:

  • A window created with show: false still gets a Playwright Page; the example runs without any window appearing.

  • On Linux CI an Electron app needs a display, so run under xvfb-run.

  • Console output of the main process is available as the console event on the ElectronApplication (since 1.42).

Android (experimental)

Playwright can automate Chrome for Android and Android WebView through ADB, via the _android namespace. It is experimental in the same sense as _electron. The requirements are an Android device or an AVD emulator, an ADB daemon running and authorised (adb devices lists the device), Chrome 105 or newer on the device, and the Chrome flag "Enable command line on non-rooted devices" turned on in chrome://flags. The known limitations are that raw USB is not supported (ADB is required), that the device must be awake to produce screenshots (turn on "Stay awake" in the developer options), and that not all of the Playwright test suite has been run against devices.

const { _android: android } = require('playwright');

(async () => {
  // Connect to the device.
  const [device] = await android.devices();
  console.log(`Model: ${device.model()}`);
  console.log(`Serial: ${device.serial()}`);
  // Take screenshot of the whole device.
  await device.screenshot({ path: 'device.png' });

  {
    // --------------------- WebView -----------------------

    // Launch an application with WebView.
    await device.shell('am force-stop org.chromium.webview_shell');
    await device.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity');
    // Get the WebView.
    const webview = await device.webView({ pkg: 'org.chromium.webview_shell' });

    // Fill the input box.
    await device.fill({
      res: 'org.chromium.webview_shell:id/url_field',
    }, 'github.com/microsoft/playwright');
    await device.press({
      res: 'org.chromium.webview_shell:id/url_field',
    }, 'Enter');

    // Work with WebView's page as usual.
    const page = await webview.page();
    await page.waitForNavigation({ url: /.*microsoft\/playwright.*/ });
    console.log(await page.title());
  }

  {
    // --------------------- Browser -----------------------

    // Launch Chrome browser.
    await device.shell('am force-stop com.android.chrome');
    const context = await device.launchBrowser();

    // Use BrowserContext as usual.
    const page = await context.newPage();
    await page.goto('https://webkit.org/');
    console.log(await page.evaluate(() => window.location.href));
    await page.screenshot({ path: 'page.png' });

    await context.close();
  }

  // Close the device.
  await device.close();
})();

The example was not run, because the test machine has no Android device, emulator or ADB daemon. The pieces to know:

API Purpose

android.devices({ host, port, omitDriverInstall })

Lists devices known to the ADB server (default 127.0.0.1:5037). omitDriverInstall skips the automatic installation of the Playwright driver on the device.

device.launchBrowser({ args, proxy, ... })

Starts Chrome and returns a BrowserContext that is used like any other. args and proxy arrived in 1.29.

device.webView({ pkg })

Finds a WebView of an application and returns an AndroidWebView; webview.page() gives the Page.

device.shell(), device.fill(), device.press(), device.tap(), device.screenshot()

Native-level interaction by resource id, text or description, outside any web content.

android.launchServer(options) and android.connect(wsEndpoint)

Run the ADB-facing part on the machine where the device is plugged in and drive it remotely (1.28).

android.launchServer() takes adbHost, adbPort, deviceSerialNumber (required when more than one device is attached), host, port and wsPath. The server listens on localhost only unless host says otherwise (since 1.45 the host option exists to restrict or widen that), and wsPath defaults to an unguessable string: anyone who knows the path can take control of the device and of the operating-system user, so never publish it or replace it with a guessable value. The client side is const device = await _android.connect(wsEndpoint).

Connecting to a Selenium Grid (experimental)

Playwright can obtain a browser from a Selenium Grid 4 hub instead of starting one locally. This is meant for teams that already operate a grid and want to run Playwright tests on it without a rewrite. Several caveats apply:

  • It is experimental. Internally Playwright asks the grid for a WebDriver session and then connects to the Chrome DevTools Protocol web socket (/session/<id>/se/cdp) that Selenium 4 exposes. If a future Selenium drops that capability, Playwright stops working with it.

  • It works only for Google Chrome and Microsoft Edge nodes, with Playwright’s chromium browser type.

  • Selenium 3 is supported on a best-effort basis: Playwright tries to talk to the grid node directly, so nodes must be reachable from the machine running the tests.

  • First make sure the grid works with plain Selenium WebDriver. If a WebDriver script cannot start a session, Playwright cannot either.

There is no code change. Set SELENIUM_REMOTE_URL to the hub and run the tests as usual; browserType.launch(), including the launch the @playwright/test runner does, is redirected to the grid.

Environment variable Purpose

SELENIUM_REMOTE_URL

URL of the hub, for example http://localhost:4444. Turns the feature on.

SELENIUM_REMOTE_CAPABILITIES

JSON-serialised extra capabilities, for grids that need vendor options ('mygrid:options') such as an operating system.

SELENIUM_REMOTE_HEADERS

JSON-serialised HTTP headers, for example an Authorization header for a hosted grid.

DEBUG=pw:browser*

Prints how Playwright connects (session id, CDP endpoint). Attach it to any problem report.

SE_NODE_GRID_URL (on the node, not on the test runner)

The address that the node reports for itself. It must be an address the test runner can reach.

SELENIUM_REMOTE_URL=http://<selenium-hub-ip>:4444 npx playwright test
SELENIUM_REMOTE_URL=http://<selenium-hub-ip>:4444 SELENIUM_REMOTE_CAPABILITIES="{'mygrid:options':{os:'windows',username:'John',password:'secure'}}" npx playwright test
SELENIUM_REMOTE_URL=http://<selenium-hub-ip>:4444 SELENIUM_REMOTE_HEADERS="{'Authorization':'Basic b64enc'}" npx playwright test
DEBUG=pw:browser* SELENIUM_REMOTE_URL=http://internal.grid:4444 npx playwright test

The capabilities and headers values above are placeholders from the official page and were not run. Real credentials belong in the CI secret store, never in the command line of a committed script.

Running it against a Docker grid

The Selenium project publishes Docker images. The compose file below starts a hub and one Chrome node (Selenium 4.51.0, Chrome 155 in the node). The line that matters for Playwright is SE_NODE_GRID_URL on the node.

services:
  selenium-hub:
    image: selenium/hub:4.51.0
    ports: ["4442:4442", "4443:4443", "4444:4444"]
  chrome:
    image: selenium/node-chrome:4.51.0
    shm_size: 2gb
    depends_on: [selenium-hub]
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=2
      # Playwright connects to the CDP endpoint the grid advertises,
      # so the node must report an address that is reachable from the test runner.
      - SE_NODE_GRID_URL=http://localhost:4444

Without SE_NODE_GRID_URL, the first attempt on the test machine failed, because the grid advertised the container’s private address as the CDP endpoint and the test runner on the host could not reach it:

<selenium> retrieved endpoint ws://172.18.0.3:4444/session/<id>/se/cdp for sessionId=<id>
<ws error> ws://172.18.0.3:4444/session/<id>/se/cdp error connect ETIMEDOUT 172.18.0.3:4444
Error: browserType.launch: WebSocket error: connect ETIMEDOUT 172.18.0.3:4444

With the variable set, the endpoint became ws://localhost:4444/session/<id>/se/cdp and the test ran in the node. The test prints where its browser runs so that the difference between a local and a grid run is visible; the configuration does not use a device descriptor, because a descriptor replaces the user agent and would hide the operating system:

import { defineConfig } from '@playwright/test';

// No device descriptor: the browser keeps its own user agent, so the test can see where it runs.
export default defineConfig({
  testDir: '.',
  testMatch: 'grid.spec.ts',
  workers: 1,
  outputDir: './test-results-grid',
  use: {
    baseURL: process.env.BASE_URL,
    testIdAttribute: 'data-testid',
  },
  projects: [{ name: 'chromium' }],
});
import { test, expect } from '@playwright/test';

test('the browser runs on the Selenium Grid node', async ({ page, browser }) => {
  await page.goto('/books');
  await expect(page.getByTestId('book-card')).toHaveCount(6);

  console.log('browser version:', browser.version());
  console.log('user agent:', await page.evaluate(() => navigator.userAgent));

  // The node is a Linux container; the test runner is not.
  if (process.env.SELENIUM_REMOTE_URL)
    expect(await page.evaluate(() => navigator.userAgent)).toContain('Linux');
});

The test runs inside the grid’s container, which is not on the same network as the host, so it reaches Bookshelf through host.docker.internal (Docker Desktop’s name for the host). The same test was then run locally for comparison:

# on the grid
SELENIUM_REMOTE_URL=http://localhost:4444 BASE_URL=http://host.docker.internal:3921 npx playwright test -c playwright.grid.config.ts
# locally
BASE_URL=http://localhost:3921 npx playwright test -c playwright.grid.config.ts
browser version: 155.0.8059.79
user agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36
  ✓  1 [chromium] › grid.spec.ts:3:5 › the browser runs on the Selenium Grid node (173ms)

browser version: 156.0.8078.4
user agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36
  ✓  1 [chromium] › grid.spec.ts:3:5 › the browser runs on the Selenium Grid node (95ms)

Both passed. Note that the grid node ran Chrome 155 while the local Playwright Chromium is build 156: a grid pins the browser version to the image, not to Playwright. Features introduced in a newer Chromium (and newer Playwright APIs that rely on them) may not be available on an older node. The grid was torn down with docker compose -p bkgrid down afterwards. The compose file also works as a standalone-node setup: with selenium/standalone-chromium a single container is hub and node, and SE_NODE_GRID_URL=http://localhost:4444 plays the same role.

Sharing a browser: launchServer, connect, bind and connectOverCDP

launchServer and connect

browserType.launchServer() starts a browser and returns a BrowserServer, whose wsEndpoint() another process can pass to browserType.connect(). This uses Playwright’s own protocol, so it exposes everything a normal launch does, but the client and the server must run the same Playwright version. launchServer accepts the launch options plus host (added in 1.45: by default the server accepts connections on all interfaces, so pass localhost to restrict it), port and wsPath, and the endpoint contains an unguessable path: anyone who can reach the endpoint controls the browser. @playwright/test can use such a server by configuring connectOptions: { wsEndpoint } under use, which is the basis of Playwright’s Docker and remote-browser setups.

browser.bind() (1.59)

browser.bind(title, options) makes a browser that a script has already launched available to other clients. It returns { endpoint }; browser.unbind() stops accepting new connections. Without host and port the browser is bound to a named pipe (a socket file), which is local to the machine; with host and port it becomes a web socket server (use port: 0 for any free port). title identifies the bound browser to clients and to the dashboard, workspaceDir associates a working directory, and metadata attaches arbitrary data.

Option Meaning

host

Host of a web socket server. When present a web socket is used instead of the named pipe.

port

Port of the web socket server; 0 lets the operating system choose.

workspaceDir

Working directory associated with this browser server.

metadata

Additional key/value data attached to the server.

Other tools attach to a bound browser by title: playwright-cli attach <title>, @playwright/mcp --endpoint=<title>, and Playwright clients through chromium.connect(endpoint). Several clients can be connected at once. playwright-cli binds all of its own browsers, playwright-cli show opens a dashboard listing every bound browser, and setting PLAYWRIGHT_DASHBOARD=1 makes the browsers of @playwright/test appear there too. These command-line and dashboard parts come from the 1.59 release notes and were not run here (see Playwright MCP & CLI for the agent tools).

The Node.js API was run: one script launches a browser and binds it to a web socket, a second client connects to it, drives a page, and the first client sees the context that the second created.

import { test, expect, chromium } from '@playwright/test';

test('browser.bind() exposes a launched browser to other clients over a web socket', async () => {
  const browser = await chromium.launch();
  const { endpoint } = await browser.bind('bookshelf-session', { host: 'localhost', port: 0 });
  expect(endpoint).toMatch(/^ws:\/\//);

  // A second, independent client connects to the same browser.
  const client = await chromium.connect(endpoint);
  const context = await client.newContext();
  const page = await context.newPage();
  await page.goto(process.env.BASE_URL + '/books');
  await expect(page.getByTestId('book-card')).toHaveCount(6);

  // The first client sees the context created by the second.
  expect(browser.contexts().length).toBe(1);

  await client.close();
  await browser.unbind();
  await browser.close();
});

The returned endpoint has a different shape depending on the options (the web socket path is an unguessable token and the pipe directory is a temporary directory; both differ at every run):

const browser = await chromium.launch();
const a = await browser.bind('bookshelf-pipe', { workspaceDir: process.cwd(), metadata: { owner: 'docs' } });
console.log('pipe endpoint:', a.endpoint);
await browser.unbind();
const b = await browser.bind('bookshelf-ws', { host: 'localhost', port: 0 });
console.log('ws endpoint:', b.endpoint);
await browser.close();
pipe endpoint: /var/folders/.../T/pw-8721d46c/browser/browser-606e3c.sock
ws endpoint: ws://[::1]:64773/8a4500c5174aae238e14efe27a013d28

A bound browser has no authentication beyond the secrecy of the endpoint, so bind to a web socket only on a trusted host.

connectOverCDP

chromium.connectOverCDP(endpointURL, options) attaches to a Chromium-based browser that is already running with remote debugging enabled (--remote-debugging-port=<port>). It is the mechanism behind the WebView2 recipe above, Electron renderers that were not started by Playwright, and several cloud-browser services. The connection speaks only CDP, so it is lower fidelity than a Playwright launch: not every feature that depends on Playwright patches in the browser is available. The browser is not started or killed by Playwright, so browser.close() only disconnects. The existing windows appear as browser.contexts()[0].pages().

Option Meaning

endpointURL

http://host:port of the debugging port or a ws:// DevTools URL. Newer versions also accept a transport object instead of a URL.

headers, timeout, slowMo

Extra HTTP headers for the connection request, connect timeout (30 seconds by default) and an artificial delay.

isLocal

Set to true when the browser is on the same host so Playwright can use file-system shortcuts (1.58).

noDefaults

Skips Playwright’s default overrides on the default context (download behaviour, focus emulation, media emulation), so that attaching to a daily-driver browser does not change its state (1.60).

artifactsDir

Where traces, downloads and other artifacts are stored while attached (1.61).

The following test exercises launchServer with connect, and connectOverCDP against a Chromium that was started with a debugging port by launchServer (port 9333 is arbitrary; in a parallel run pick a port per worker):

import { test, expect, chromium } from '@playwright/test';

test('launchServer + connect', async () => {
  const server = await chromium.launchServer({ host: 'localhost' });
  const browser = await chromium.connect(server.wsEndpoint());
  const page = await browser.newPage();
  await page.goto(process.env.BASE_URL + '/books');
  await expect(page).toHaveTitle(/Catalogue/);
  await browser.close();
  await server.close();
});

test('connectOverCDP to a browser started with --remote-debugging-port', async () => {
  const server = await chromium.launchServer({ args: ['--remote-debugging-port=9333'] });
  const browser = await chromium.connectOverCDP('http://localhost:9333');
  const context = browser.contexts()[0];
  const page = await context.newPage();
  await page.goto(process.env.BASE_URL + '/books');
  await expect(page.getByTestId('book-card')).toHaveCount(6);
  await browser.close();
  await server.close();
});

Both tests passed. The debugging port is unauthenticated: bind it to the loopback interface, never expose it to a network.

The Screencast API (1.59)

page.screencast gives one interface for everything that shows a page over time: recording video with exact start and stop points, streaming JPEG frames to a callback, decorating interactions (action annotations) and drawing overlays and chapter titles on top of the page. It complements the video option of the test runner (see Screenshots, Videos & Traces): video records a whole test automatically, while page.screencast.start() lets a script record only the part it cares about, with annotations the script controls. Playwright describes one use case as an "agentic video receipt": a coding agent records a walkthrough with chapters and annotations as evidence of its work.

Method Description

start(options)

Starts the screencast and returns a Disposable. With path the video is written there when the screencast stops; with onFrame JPEG frames are delivered to the callback; both can be combined.

stop()

Stops the screencast and finishes the video file, if any.

showActions(options)

Turns on annotations for the elements the script interacts with; returns a Disposable. hideActions() removes them.

showChapter(title, options)

Shows a centred title card (blurred backdrop) with an optional description, removed after duration milliseconds (2000 by default).

showOverlay(html, options)

Adds an overlay with arbitrary HTML on top of the page until disposed, or for duration milliseconds; returns a Disposable.

hideOverlays() and showOverlays()

Hide overlays without removing them, and show them again.

The start options:

Option Meaning

path

File that receives the video (WebM) when the screencast stops. Providing it starts video recording.

onFrame

Callback receiving { data, timestamp, viewportWidth, viewportHeight }: a JPEG-encoded Buffer, the time the browser presented the frame in milliseconds since the Unix epoch (since 1.61), and the viewport size at capture time.

size

Maximum frame size, { width, height }. The frame is scaled to keep the aspect ratio and may be smaller. If a screencast is already active (for example started by tracing or by video recording), the existing configuration wins and the size option may be ignored. Default: the viewport scaled down to fit 800 by 800.

quality

JPEG quality of frames, 0 to 100.

fps

Frame rate of the video, only together with path; default 25 (1.64). Firefox and WebKit capture at most 25 frames per second, so a higher value only helps in Chromium.

The showActions options: position (top-left, top, top-right by default, bottom-left, bottom, bottom-right), duration of each annotation (500 ms by default, all decorations fade out over it), cursor (pointer animates a mouse pointer between action points, none turns it off; 1.61), style with CSS declarations for point, highlight and title (1.64), and the deprecated fontSize, replaced by style: { title: 'font-size: 32px' }.

The runner offers the same annotations declaratively: the video option of the configuration accepts show: { actions: { position: 'top-left' }, test: { position: 'top-right' } }, as shown in the 1.59 release notes.

Recording a walkthrough

The test starts a screencast of a Bookshelf search, with the cursor, action titles, a chapter card and a "REC" overlay, and stops it. It asserts only that a non-empty video file is produced; to look at the result, open the .webm file.

import { test, expect } from '@playwright/test';
import fs from 'fs';

test('screencast records a video with action annotations and chapters', async ({ page }, testInfo) => {
  const video = testInfo.outputPath('walkthrough.webm');
  await page.goto('/books');

  await page.screencast.start({ path: video, size: { width: 1280, height: 720 }, fps: 25 });
  const actions = await page.screencast.showActions({ position: 'top-left', cursor: 'pointer' });

  await page.screencast.showChapter('Search the catalogue', {
    description: 'Looking for The Quiet Compiler',
    duration: 800,
  });
  const badge = await page.screencast.showOverlay('<div style="position:fixed;top:8px;right:8px;background:#b91c1c;color:#fff;padding:4px 8px">REC</div>');

  await page.getByTestId('search-input').fill('quiet');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByTestId('book-card')).toHaveCount(1);

  await badge.dispose();   // remove the overlay
  await actions.dispose(); // stop annotating actions
  await page.screencast.stop();

  expect(fs.statSync(video).size).toBeGreaterThan(1000);
  console.log('video bytes:', fs.statSync(video).size);
});

The run produced a WebM file of about 460 KB (video bytes: 459803). The Disposable objects returned by start, showActions and showOverlay have a dispose() method, so each decoration can be removed independently of the others.

Streaming frames

With onFrame the same API becomes a frame stream, useful for live previews, thumbnails or feeding images to a vision model. Frames are JPEG, so the first two bytes are FF D8, which the test checks.

test('screencast delivers JPEG frames to a callback', async ({ page }) => {
  const frames: { bytes: number; w: number; h: number; ts: number }[] = [];
  await page.goto('/books');

  await page.screencast.start({
    size: { width: 640, height: 480 },
    quality: 60,
    onFrame: ({ data, timestamp, viewportWidth, viewportHeight }) => {
      frames.push({ bytes: data.length, w: viewportWidth, h: viewportHeight, ts: timestamp });
      // JPEG files start with the bytes FF D8
      expect(data[0]).toBe(0xff);
      expect(data[1]).toBe(0xd8);
    },
  });
  await page.getByTestId('search-input').fill('quiet');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByTestId('book-card')).toHaveCount(1);
  await page.screencast.stop();

  console.log('frames:', frames.length, 'first:', JSON.stringify(frames[0]));
  expect(frames.length).toBeGreaterThan(0);
});
frames: 5 first: {"bytes":10103,"w":1280,"h":720,"ts":1792958167921.269}

Five frames arrived for a short interaction. Frames are produced when the page changes, not at a fixed rate, so an idle page yields few frames; do not use the count as a timing measure.

Pitfalls:

  • Frame callbacks run while the page is being driven. Keep them fast and do heavy work (writing files, calling a model) outside, for example by pushing the buffer to a queue.

  • Overlays are ordinary DOM injected into the page. They appear in the video and in screenshots taken while they are shown, and an overlay with a high z-index can intercept pointer events in your own tests; dispose of it or keep pointer-events: none in its markup when interacting underneath.

  • A screencast that is already running (because of trace or video in the configuration) keeps its own size and quality; an explicit size passed to start may be ignored in that case.

Extensibility: custom selector engines

A selector engine is a small JavaScript object that Playwright runs inside the page, so that page.locator('myengine=...') can find elements by rules the built-in locators do not offer. Register it with selectors.register(name, script, options) before any page is created. The engine has two functions:

  • query(root, selector) returns the first matching element in the root’s subtree (or null).

  • queryAll(root, selector) returns an array of all matching elements.

name may only contain letters, digits and underscores, and selectors are written name=body. script is either a function that returns the engine, a string, or an object with a path to a JavaScript file or raw content. A function is serialised and evaluated in the page, so it cannot use variables from its closure or imports.

The contentScript option ({ contentScript: true }) runs the engine in an isolated JavaScript world: it sees the same DOM but not the page’s own JavaScript objects, which protects it from a page that alters global prototypes. All of Playwright’s built-in engines run this way. Playwright documents that running as a content script is not guaranteed when the engine is combined with other custom engines. Without the option the engine runs in the page itself and can call application functions.

Register the engine once per worker through an automatic worker-scoped fixture, so every test file that imports the extended test has it:

import { test as base } from '@playwright/test';

export { expect } from '@playwright/test';

// Must be a function that evaluates to a selector engine instance.
// It is serialised and evaluated in the page, so it cannot use closure variables.
const createTagNameEngine = () => ({
  // Returns the first element matching the selector in the root's subtree.
  query(root: Element | Document, selector: string) {
    return root.querySelector(selector);
  },
  // Returns all elements matching the selector in the root's subtree.
  queryAll(root: Element | Document, selector: string) {
    return Array.from(root.querySelectorAll(selector));
  },
});

// An engine that matches by the data-testid attribute and a prefix.
const createTestIdPrefixEngine = () => ({
  query(root: Element | Document, prefix: string) {
    return root.querySelector(`[data-testid^="${prefix}"]`);
  },
  queryAll(root: Element | Document, prefix: string) {
    return Array.from(root.querySelectorAll(`[data-testid^="${prefix}"]`));
  },
});

export const test = base.extend<{}, { selectorRegistration: void }>({
  // Register selectors once per worker, before any page is created.
  selectorRegistration: [async ({ playwright }, use) => {
    await playwright.selectors.register('tag', createTagNameEngine);
    await playwright.selectors.register('idprefix', createTestIdPrefixEngine, { contentScript: true });
    await use();
  }, { scope: 'worker', auto: true }],
});

The tests use both engines with locators, chain them with built-in locators and assert with the normal web-first matchers:

import { test, expect } from './selectors-base';

test('a custom tag= engine works with locators and built-in locators', async ({ page }) => {
  await page.goto('/books');

  await expect(page.locator('tag=h1')).toHaveCount(1);
  await expect(page.locator('tag=li.card')).toHaveCount(6);

  // Chain with built-in locators.
  await page.locator('tag=main').getByRole('button', { name: 'Search' }).click();
  await expect(page).toHaveURL(/\/books/);
});

test('an engine registered as a content script can still read the DOM', async ({ page }) => {
  await page.goto('/books');
  const cards = page.locator('idprefix=book-card');
  console.log('cards via idprefix engine:', await cards.count());
  await expect(cards.first()).toBeVisible();
});

Both passed (cards via idprefix engine: 6). The "tag" engine from the official page is really a CSS engine, because it passes its selector to querySelector, so tag=li.card works too; a useful engine encodes a rule that CSS cannot, such as "the element that a framework attached to this component instance". The same registry exposes selectors.setTestIdAttribute(name), which changes the attribute used by getByTestId (the example configuration uses the testIdAttribute option instead). Playwright removed the experimental _react and _vue engines in 1.58; prefer role, text and test-id locators described in Locators, Actions & Auto-waiting, and write a custom engine only when those cannot express the rule. Custom engines add a maintenance cost: they run in every page and depend on page internals.

What changed recently

Version Change

1.64

fps option of screencast.start() and of video recording; style option (point, highlight, title) of screencast.showActions(); fontSize deprecated in favour of style.title.

1.61

cursor option of screencast.showActions(); onFrame receives a frame timestamp; artifactsDir option in connectOverCDP().

1.60

noDefaults option of connectOverCDP(), to attach to a browser without changing its default context; the logger option of connect and connectOverCDP removed in favour of tracing.

1.59

page.screencast introduced (start, stop, showActions, hideActions, showChapter, showOverlay, showOverlays, hideOverlays); browser.bind() and browser.unbind(); playwright-cli binding and dashboard; artifactsDir and chromiumSandbox options of electron.launch().

1.58

isLocal option of connectOverCDP(); the _react and _vue selector engines and the :light suffix removed.

1.55

Chromium extension Manifest V2 no longer supported.

1.49

The chromium channel selects Chromium’s new headless mode, the supported route to run extensions headless.

1.45

host option of browserType.launchServer(); host option of android.launchServer().

1.28 and 1.29

android.launchServer() and android.connect() (1.28); args and proxy for androidDevice.launchBrowser() (1.29).

The Selenium Grid integration, Electron and Android remain marked as experimental in the 1.64 documentation, and WebView2 support is documented but unchanged in the recent release notes.