Run Playwright tests
Your existing Playwright test code keeps working against Chrome on the emulator. The CDP connection terminates on the emulator's host, so tests stay responsive even when your test runner is in another region.
Before you start
-
LIM_API_KEYset up as in the Quickstart. -
Node 20 or newer.
-
The
playwrightpackage in your project, at a version the Playwright service runs (see Match the Playwright version). The Android device API ships in the regularplaywrightpackage under an internal export (_android).npm install [email protected]The TypeScript sample below selects the 1.60.0 service. Instances created from the Python or Go SDK run the default 1.56.1 service, so install
[email protected]for those.
Choose how Playwright connects
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 service (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 Playwright service; the tunnel path is covered at the end.
Create the instance
Two things change relative to a plain Android create call. First, turn on the Playwright service (spec.sandbox.playwrightAndroid) so the instance exposes a CDP WebSocket, and pick its Playwright version. 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, version: '1.60.0-lim.1' },
},
// 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 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",
},
},
],
},
)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",
},
},
},
},
})The Python and Go SDKs cover the create call, but only the TypeScript SDK exposes the version field. From Python or Go, the service runs its default version; send version through the REST API if you need another one. Playwright's Android device API ships only in the Node binding, so the rest of the page stays in TypeScript.
Match the Playwright version
spec.sandbox.playwrightAndroid.version selects the Playwright server the service runs: '1.56.1-lim.1' (the default) or '1.60.0-lim.1'. Install the matching client in your project, [email protected] or [email protected]. A client on another version fails the WebSocket handshake with a "Playwright version mismatch" error.
reuseIfExists returns an existing instance only when its labels match exactly, so give each CI job or dev session its own label set. See Concepts for how reuse and placement interact.
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 instance token. Authenticates the URL above. It's
instance.status.token.
Connect Playwright
Only the Node binding of Playwright exposes the Android device API; the other Playwright language bindings do not. 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 instance 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 instance token as a query param.
const serviceUrl = instance.status.sandbox?.playwrightAndroid?.url;
if (!serviceUrl) throw new Error('Playwright Android sandbox URL not found');
const cdpUrl = `${serviceUrl}?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.
Connect through a local ADB tunnel instead
If you'd rather not enable the Playwright service, 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 the Appium page 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?