Llim.run

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

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:

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

flask-conical

Appium

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

tablet-smartphone

Run an emulator

Instance lifecycle, the ADB tunnel, and the Android device client.

package

Asset Storage

App assets for native testing, installed at boot.