Llim.run

Playwright

Playwright on Limrun automates Chrome on Android emulator instances. Your existing Playwright test code keeps working.

Before you start

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:

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

Appium

Cross-platform mobile automation: iOS Safari + native, Android native apps.

Run an Android Emulator

Instance lifecycle, ADB tunnel internals, and the Android control SDK.

Asset Storage

Configuration assets like the Chrome flag used here, plus app assets for native testing.