Playwright
Playwright on Limrun automates Chrome on Android emulator instances. Your existing Playwright test code keeps working.
Before you start
-
A Limrun API key in
LIM_API_KEY(get one from console.limrun.com). If you've never used the CLI before, walk through the Quickstart first. -
Node 20 or newer.
-
[email protected]in your project. The Android device API ships with the regularplaywrightpackage under an internal export (_android). The sub-sandbox server is pinned to1.56, so newer or older client versions reject the WebSocket handshake with a "Playwright version mismatch" error.npm install [email protected]
What's supported
Playwright on Limrun is Android Chrome only. Each Android instance ships with Chrome, and Limrun exposes Chrome's DevTools Protocol (CDP) endpoint to your test as a single WebSocket URL. iOS Safari and desktop Chromium aren't routed through this integration; if you want Safari, look at Appium.
There are two ways to reach that CDP endpoint, and the choice has real consequences for test speed.
The Playwright Android sub-sandbox (recommended). Limrun runs a Playwright-aware service on the same host as the emulator and exposes its WebSocket to you. Your test attaches to that WebSocket, and the CDP-to-emulator hops stay on the emulator's host. As a concrete reference, on one cross-continent run (laptop in Bengaluru, instance in Helsinki), the runnable example below (page load, three clicks, screenshot) finished in about 4.5 seconds.
A local ADB tunnel. You can open an ADB tunnel and let Playwright drive the device through adb. It works, but every CDP call goes from your laptop → ADB tunnel → emulator. CDP is a chatty protocol (each click or locator lookup is several request-response round-trips), so suites get noticeably slower the further your test runner is from the instance's region. The rest of this page uses the sub-sandbox; the tunnel path is covered at the end.
Provision the instance
Two things change relative to a plain Android create call. First, turn on the Playwright sub-sandbox so the instance exposes a CDP WebSocket. Second, ship Chrome a flag that lets it accept command-line arguments from Playwright (Chrome on Android ignores them by default). Both go into a single create call:
import { Limrun } from '@limrun/api';
const limrun = new Limrun({ apiKey: process.env.LIM_API_KEY });
const instance = await limrun.androidInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { name: 'playwright' } },
spec: {
// Without this flag, no sandbox URL is returned.
sandbox: {
playwrightAndroid: { enabled: true },
},
// Flips Chrome's "accept command-line flags" switch at boot. Playwright
// needs it to launch the remote browser.
initialAssets: [
{
kind: 'Configuration',
configuration: {
kind: 'ChromeFlag',
chromeFlag: 'enable-command-line-on-non-rooted-devices@1',
},
},
],
},
});import (
"context"
"os"
limrun "github.com/limrun-inc/go-sdk"
"github.com/limrun-inc/go-sdk/option"
"github.com/limrun-inc/go-sdk/packages/param"
)
lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
instance, err := lim.AndroidInstances.New(context.TODO(), limrun.AndroidInstanceNewParams{
Wait: param.NewOpt(true),
ReuseIfExists: param.NewOpt(true),
Metadata: limrun.AndroidInstanceNewParamsMetadata{
Labels: map[string]string{"name": "playwright"},
},
Spec: limrun.AndroidInstanceNewParamsSpec{
// Turns on the per-instance Playwright service.
Sandbox: limrun.AndroidInstanceNewParamsSpecSandbox{
PlaywrightAndroid: limrun.AndroidInstanceNewParamsSpecSandboxPlaywrightAndroid{
Enabled: param.NewOpt(true),
},
},
// Flips Chrome's "accept command-line flags" switch at boot.
InitialAssets: []limrun.AndroidInstanceNewParamsSpecInitialAsset{
{
Kind: "Configuration",
Configuration: limrun.AndroidInstanceNewParamsSpecInitialAssetConfiguration{
Kind: "ChromeFlag",
ChromeFlag: "enable-command-line-on-non-rooted-devices@1",
},
},
},
},
})import os
from limrun_api import Limrun
limrun = Limrun(api_key=os.environ["LIM_API_KEY"])
instance = limrun.android_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"name": "playwright"}},
spec={
# Turns on the per-instance Playwright service.
"sandbox": {"playwright_android": {"enabled": True}},
# Flips Chrome's "accept command-line flags" switch at boot.
"initial_assets": [
{
"kind": "Configuration",
"configuration": {
"kind": "ChromeFlag",
"chrome_flag": "enable-command-line-on-non-rooted-devices@1",
},
},
],
},
)The Python SDK covers control-plane calls like this one. Playwright's Android device API ships only in the Node binding, so the rest of the page stays in TypeScript.
reuseIfExists matches existing instances by labels and region. If you don't pin spec.region, the platform may schedule your next create in a different region (closer to your current IP, for example), and the label match misses, so a fresh instance gets created instead. For repeatable reuse across CI runs or local dev sessions, pin region: 'us-west' (or whichever region you prefer) explicitly.
When the call resolves, two things on the instance object matter for Playwright:
- The CDP WebSocket URL. This is the endpoint Playwright connects to. On the returned object, it's
instance.status.sandbox.playwrightAndroid.url. - The bearer token. Authenticates the URL above. It's
instance.status.token.
Connect Playwright
playwright-python, playwright-java, and playwright-dotnet don't expose the Android device API. If your stack can't run Node, use the Appium Android flow instead.
The API lives behind Playwright's internal _android export. Pass the CDP URL with the bearer token as a query parameter, then go through Playwright's regular Android flow: warm up Chrome, launch the browser, open a page.
import { _android as android } from 'playwright';
// Build the CDP URL with the token as a query param.
const cdpUrl =
instance.status.sandbox.playwrightAndroid!.url +
`?token=${instance.status.token}`;
// Same shape as Playwright's local `android.devices()`, but routed through Limrun.
const device = await android.connect(cdpUrl);
// Chrome on a fresh Android profile won't accept CDP until it's been launched
// once and dismissed first-run prompts. Skip this on warm instances.
await device.shell('am start com.android.chrome/com.google.android.apps.chrome.Main');
await new Promise((r) => setTimeout(r, 1_000));
await device.shell('am force-stop com.android.chrome');
// From here, test code is identical to local Chrome: newPage, goto, locator, screenshot.
const browser = await device.launchBrowser();Run a test
Once launchBrowser() returns, you're back on the standard Playwright API:
const page = await browser.newPage();
await page.goto('https://github.com/microsoft/playwright');
await page.waitForURL('https://github.com/microsoft/playwright');
console.log(await page.title());
await page.locator('a[title=".github"]').first().click();
await page.locator('a[title="workflows"]').first().click();
await page.screenshot({ path: 'screenshot.png' });
// Clean up. `device.close()` releases the CDP session; the instance keeps
// running until you delete it (or it ages out via the inactivity timeout).
await device.close();
await limrun.androidInstances.delete(instance.metadata.id);The full runnable example is at typescript-sdk/examples/playwright.
The local-ADB alternative
If you'd rather not enable the sub-sandbox, you can open an ADB tunnel and use Playwright's normal android.devices() flow:
// Open the tunnel through the CLI in another terminal, or programmatically
// via `client.startAdbTunnel()` (see Appium > Android for the SDK call).
// Then:
const [device] = await android.devices();This is the path the Appium page uses for UiAutomator2. It works for Playwright, but every CDP call traverses your laptop, so expect noticeably slower test runs.
Troubleshooting
Next steps
Was this guide helpful?