Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
125aa03
feat(testmu): add TestMu AI device provider package
amankansal-lt Oct 2, 2026
98c60c1
docs(integrations): document @e2e-dev/testmu
amankansal-lt Oct 2, 2026
9a797eb
fix(testmu): keep the lease alive while the session starts
amankansal-lt Oct 2, 2026
1ef8f77
fix(mobile): read XCUIElementType-prefixed iOS element types
amankansal-lt Oct 2, 2026
8e10ef6
Merge pull request #1 from amankansal-lt/feat/testmu-ai-deviceswyec
anurag-lambdatest Oct 2, 2026
2f73783
fix(testmu): describe when the session starts
amankansal-lt Oct 2, 2026
a27b1c0
feat(testmu): name each slot's session by default
amankansal-lt Oct 2, 2026
e1eeefd
feat(testmu): accept device-feature options
amankansal-lt Oct 2, 2026
526abdd
feat(testmu): record attempt video from the TestMu AI session
amankansal-lt Oct 2, 2026
5c10388
chore(testmu): prune old run state
amankansal-lt Oct 2, 2026
d440333
docs(testmu): explain app handling and the daemon's credentials
amankansal-lt Oct 2, 2026
8125ebd
fix(testmu): start the attempt's video at the session's start time
amankansal-lt Oct 2, 2026
7427276
docs: run the TestMu AI example on one app, and fix a list sentence
amankansal-lt Oct 2, 2026
33e370d
fix(testmu): keep the run's credentials out of the host's environment
amankansal-lt Oct 2, 2026
4a362a9
fix(testmu): validate every option at config load
amankansal-lt Oct 2, 2026
3a45875
docs(testmu): explain the lease window without agent-device internals
amankansal-lt Oct 2, 2026
20ad572
fix(testmu): refuse an API endpoint that carries credentials
amankansal-lt Oct 2, 2026
6026844
fix(testmu): look up the recorded session among the user's own
amankansal-lt Oct 2, 2026
dae2c2f
fix(testmu): never start a daemon with another run's credentials
amankansal-lt Oct 2, 2026
431bb4c
fix(testmu): prune only run directories the provider marked
amankansal-lt Oct 2, 2026
ad6a8ea
docs(testmu): bound the lease-window claim and note one account per p…
amankansal-lt Oct 2, 2026
3f044c5
Merge pull request #2 from amankansal-lt/fix/testmu-review-followups
amankansal-lt Oct 2, 2026
8773058
Merge tester-army/e2e main into LambdaTest/e2e main
amankansal-lt Oct 5, 2026
4a6def6
chore(testmu): follow upstream's agent-device 0.21.20 pin and Node floor
amankansal-lt Oct 5, 2026
3cc2ef4
Merge pull request #3 from amankansal-lt/chore/sync-upstream-main
amankansal-lt Oct 5, 2026
fe91e5f
feat(testmu): hosted Chrome and Edge for the web engine with testmuBr…
SahilSawLT Oct 5, 2026
7bfdd6e
Merge pull request #4 from SahilSawLT/feat/testmu-browsers
amankansal-lt Oct 6, 2026
24c101e
Merge tester-army/e2e main into LambdaTest/e2e main
amankansal-lt Oct 6, 2026
24a10a4
Merge pull request #5 from amankansal-lt/chore/sync-upstream-main-2
amankansal-lt Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ios-webdriver-element-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2e-dev/mobile': patch
---

iOS devices driven through agent-device's WebDriver runtime (hosted Appium clouds such as TestMu AI, BrowserStack, and AWS Device Farm) resolve roles like a local simulator does. That runtime reports XCUITest class names (`XCUIElementTypeButton`, `XCUIElementTypeStaticText`) where the native runner reports `Button` and `StaticText`, and the engine read them as unknown types, so `getByRole('button', 'CPU Load')` matched nothing and `observe` listed the node as `xcuielement-type-button`. The `XCUIElementType` prefix is now dropped before the type is mapped, so a button is a `button`, a tab bar's buttons are `tab`s, and the navigation bar titles the screen.
5 changes: 5 additions & 0 deletions .changeset/testmu-browsers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@e2e-dev/testmu": minor
---

`@e2e-dev/testmu/web`: `web({ browser: testmuBrowsers() })` runs a web target in TestMu AI (formerly LambdaTest) hosted Chrome or Edge on Windows and macOS, one session per worker slot, or one per attempt with `scope: 'attempt'`. A session starts when its CDP websocket opens and ends when the engine closes it, so the provider only builds the CDP URL (`hub` `cdp.lambdatest.com`) from `browserName`, `browserVersion`, `platform`, `geoLocation`, `timezone`, and further `LT:Options` `capabilities`, with `LT_USERNAME` and `LT_ACCESS_KEY` from the run's environment, which never appear in a log line. `route` is `/puppeteer` by default, which serves raw CDP on every machine; `/playwright-cdp` labels sessions as Playwright but serves raw CDP only on machines with TestMu AI's newest backend, which is still rolling out. Sessions are named as `testmu()`'s are: `build` defaults to the run id, so a run's device and browser sessions share one build, `sessionName` to `e2e-<run id>-<target>-<slot or attempt id>`, and `project` (default `e2e`) is sent as `LT:Options.project`. `idleTimeout` defaults to 600 seconds. Unknown options, a `scope` or `route` outside their values, a `hub` with a scheme or path, a non-Chromium `browserName`, an empty or non-string string option, `null` for any option, and `capabilities` that set what the provider sets (`user`, `accessKey`, `build`, `name`, `project`, `platform`, `geoLocation`, `timezone`, `browserName`, `browserVersion`, or a nested `LT:Options`) fail with `INVALID_CONFIG` at config load. The package root stays device-only, and `@e2e-dev/mobile`, `@e2e-dev/web`, and `agent-device` are optional peers, so a project installs only the side it uses.
5 changes: 5 additions & 0 deletions .changeset/testmu-devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@e2e-dev/testmu": minor
---

`@e2e-dev/testmu`: `mobile({ device: testmu({ device, osVersion, app }) })` runs a mobile target on TestMu AI (formerly LambdaTest) Android emulators, iOS simulators, and real devices (`deviceType: 'real'`), one per worker slot, through agent-device's `testmu` cloud provider. Each slot leases a device from an agent-device daemon the provider starts for the run under `stateDir` (default `.e2e/testmu`), in a directory per run that it marks as its own and that later runs remove once nothing in it has changed for 24 hours; allocating a lease starts its TestMu AI session, which installs `app` (an `lt://` id, an `https` URL, or a local path), so every slot is billed from the moment it is leased, and releasing the lease ends it. It reads `LT_USERNAME` and `LT_ACCESS_KEY` from the run's environment and fails the lease before starting anything when either is missing, sets up the device with `orientation`, `geoLocation`, `timezone`, `language`, `locale`, and `appiumVersion` when given, groups sessions on the dashboard under `project` (default `e2e`) and `build` (default the run id) and names each slot's session `sessionName` (default `e2e-<run id>-<target>-<slot>`, with `-<slot>` appended to a given name when the target has more than one slot), allocates each lease with agent-device's longest inactivity window and heartbeats it every 2 minutes while the run holds it, so a session that takes up to 10 minutes to start keeps its lease, links TestMu AI's recording of the slot's session as the video of an attempt that records one (the whole session, starting at the session's start time), found through TestMu AI's sessions API by the session's build and name, releases a lease it allocated when the run was interrupted, and releases each lease once. It needs an agent-device that includes the `testmu` provider, which no published release does yet, with `@e2e-dev/mobile`'s agent-device overridden to the same version; `agent-device` and `@e2e-dev/mobile` are its peers.
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ suites that consume the built packages the way a user would.
keeps it current on reruns, writing the same text to the job summary. It
reads the token, event, and repository when the run finishes, never at
config load, and renders the page with `renderMarkdownReport` from `e2e`.
- `packages/testmu` — the published `@e2e-dev/testmu` package: TestMu AI
(formerly LambdaTest) hosted Android emulators, iOS simulators, and real
devices for the mobile engine (`DeviceProvider`). agent-device's `testmu`
cloud provider is the vendor client: the package allocates agent-device
leases from a daemon it starts per run, so `agent-device` (which must be
the same copy `@e2e-dev/mobile` uses) and `@e2e-dev/mobile` are its peers.
`@e2e-dev/testmu/web` exports `testmuBrowsers()`, TestMu AI hosted Chrome
and Edge for the web engine (`BrowserProvider`): a session starts when its
CDP websocket opens and ends when it closes, so it only builds the URL and
calls no API. The root stays device-only, so neither side's types or
imports reach the other. Every
peer (`@e2e-dev/mobile`, `@e2e-dev/web`, `agent-device`) is optional, so a
project installs only the side it uses.
- `apps/testbed` (`@e2e-dev/testbed`, private) — dogfood project that
consumes the **built** packages like a real user would: the playground app
where every runner feature (sessions, routes, downloads, frames, uploads,
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,5 +255,5 @@ build against, and what a third-party engine builds against too. A change to
that contract bumps all three packages together in one release, with a
changeset for each, so an engine and a runner from the same release always
match. Engines declare a peer range on `e2e` that points one way only
(engine to runner, `>=x <1`), and each integration (`@e2e-dev/kernel`, `@e2e-dev/eas`) does
(engine to runner, `>=x <1`), and each integration (`@e2e-dev/kernel`, `@e2e-dev/eas`, `@e2e-dev/testmu`) does
the same on the engines it plugs into; do not make it mutual or narrow it.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ suite.
| [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. |
| [`@e2e-dev/eas`](https://www.npmjs.com/package/@e2e-dev/eas) | EAS Simulators hosted iOS simulators and Android emulators for the mobile engine. |
| [`@e2e-dev/decision`](https://e2e.tester.army/docs/decision-models) | Decision-model executors for bounded semantic actions and assertions. |
| [`@e2e-dev/testmu`](https://www.npmjs.com/package/@e2e-dev/testmu) | TestMu AI (formerly LambdaTest) hosted Android emulators, iOS simulators, and real devices for the mobile engine, and hosted Chrome and Edge for the web engine (`@e2e-dev/testmu/web`). |

## Documentation

Expand Down
5 changes: 3 additions & 2 deletions docs/browser.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ lacks: it asks for a browser when one is needed and gives it back when its
scope ends, on every exit path, so a session billed by the minute stops
when the run no longer uses it. The engine knows no vendor: for
[Kernel](/integrations/kernel), `kernel()` from `@e2e-dev/kernel` is the
provider, and for any other service a provider is a few dozen lines in your
project. Add `viewport: null` when
provider, for [TestMu AI](/integrations/testmu-browsers),
`testmuBrowsers()` from `@e2e-dev/testmu/web` is the provider, and for any other
service a provider is a few dozen lines in your project. Add `viewport: null` when
the service shows the window in a live view: the page then
[fills the window](/web#fill-the-window) instead of a fixed 1280 by
720 area of it.
Expand Down
4 changes: 3 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@
"pages": [
"integrations/index",
"integrations/kernel",
"integrations/eas"
"integrations/eas",
"integrations/testmu",
"integrations/testmu-browsers"
]
},
{
Expand Down
36 changes: 36 additions & 0 deletions docs/examples/mobile/testmu.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { E2EConfig } from 'e2e';
import { mobile } from '@e2e-dev/mobile';
import { testmu } from '@e2e-dev/testmu';

const apk = 'https://prod-mobile-artefacts.lambdatest.com/assets/docs/proverbial_android.apk';
const ipa = 'https://prod-mobile-artefacts.lambdatest.com/assets/docs/proverbial_ios.ipa';

export default {
targets: [
{
name: 'android-emulator',
engine: mobile({
platform: 'android',
device: testmu({ device: 'Galaxy S22 Ultra 5G', osVersion: '14', app: apk }),
}),
app: { bundleId: 'com.lambdatest.proverbial' },
},
{
name: 'ios-real',
engine: mobile({
platform: 'ios',
device: testmu({ device: 'iPhone 16', osVersion: '18', app: ipa, deviceType: 'real' }),
}),
app: { bundleId: 'proverbial' },
},
{
name: 'android-real',
engine: mobile({
platform: 'android',
device: testmu({ device: 'Pixel 6', osVersion: '14', app: apk, deviceType: 'real' }),
}),
app: { bundleId: 'com.lambdatest.proverbial' },
},
],
workers: 1,
} satisfies E2EConfig;
9 changes: 9 additions & 0 deletions docs/examples/mobile/testmu.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { test } from '@e2e-dev/mobile';
import { expect } from 'e2e';

test('Proverbial home screen', async ({ app, screen }) => {
await app.open();
await expect(screen.getByText('Proverbial')).toBeVisible();
Comment thread
amankansal-lt marked this conversation as resolved.
// Android labels the button GEOLOCATION, iOS GeoLocation.
await expect(screen.getByRole('button', /^geolocation$/i)).toBeVisible();
});
13 changes: 13 additions & 0 deletions docs/examples/web/testmu-browsers.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';
import { testmuBrowsers } from '@e2e-dev/testmu/web';

export default {
targets: [
{
name: 'testmu-web',
engine: web({ browser: testmuBrowsers(), viewport: null }),
app: { url: 'https://staging.example.com' },
},
],
} satisfies E2EConfig;
6 changes: 6 additions & 0 deletions docs/integrations/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ integration decides where it comes from.
<Card title="EAS Simulators" icon="/images/integrations/expo.svg" href="/integrations/eas">
`@e2e-dev/eas`: hosted iOS simulators and Android emulators for mobile targets.
</Card>
<Card title="TestMu AI" icon="mobile-screen" href="/integrations/testmu">
`@e2e-dev/testmu`: hosted Android emulators, iOS simulators, and real devices for mobile targets.
</Card>
<Card title="TestMu AI browsers" icon="cloud" href="/integrations/testmu-browsers">
`@e2e-dev/testmu`: hosted Chrome and Edge on Windows and macOS for web targets, one session per worker or per test.
</Card>
</CardGroup>

A service without an integration is one small provider in your project:
Expand Down
171 changes: 171 additions & 0 deletions docs/integrations/testmu-browsers.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
title: TestMu AI browsers
sidebarTitle: TestMu AI browsers
description: Run web targets in TestMu AI's hosted Chrome and Edge with the testmuBrowsers() browser provider from @e2e-dev/testmu/web.
---

`testmuBrowsers()` from `@e2e-dev/testmu/web` is a
[browser provider](/browser#hosted-browsers) for
[TestMu AI](https://www.lambdatest.com) (formerly LambdaTest). By default each worker slot gets its
own TestMu AI session for the run; `scope: 'attempt'` gives every test
attempt its own session instead. Tests and CI stay the same; the config
changes only in the target's `browser`.

## Setup

<CodeGroup>
```bash npm
npm install --save-dev @e2e-dev/testmu
```

```bash pnpm
pnpm add -D @e2e-dev/testmu
```

```bash bun
bun add -d @e2e-dev/testmu
```
</CodeGroup>

```ts title="e2e.config.ts"
import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';
import { testmuBrowsers } from '@e2e-dev/testmu/web';

export default {
targets: [
{
name: 'testmu-web',
engine: web({ browser: testmuBrowsers(), viewport: null }),
app: { url: 'https://staging.example.com' },
},
],
} satisfies E2EConfig;
```

The browser provider lives at `@e2e-dev/testmu/web`, so a web-only project
needs neither `@e2e-dev/mobile` nor agent-device. The package root exports
the [device provider](/integrations/testmu), which uses both.

Set `LT_USERNAME` and `LT_ACCESS_KEY` in the environment `e2e run` starts in,
such as CI secrets. To get them, [sign up for TestMu AI](https://accounts.lambdatest.com/register)
or log in to your account, then copy your username and access key from
**Account Settings → Password & Security → Username and Access Key**
([accounts.lambdatest.com/security/username-accesskey](https://accounts.lambdatest.com/security/username-accesskey)).

Each worker gets its own session, so `workers` sets how
many run at once. Keep it within your TestMu AI plan's parallel sessions:
a session beyond it waits in TestMu AI's queue, and the web engine gives a
session 60 seconds to accept the CDP connection, so one that waits longer
fails its attempt. See [Limits](#limits).

## Options

| Option | Default | Meaning |
| ------ | ------- | ------- |
| `scope` | `'worker'` | `'worker'`: one session per worker slot for the run. `'attempt'`: a fresh session per test attempt, retries included. See [Scopes](#scopes). |
| `browserName` | `'Chrome'` | `'Chrome'` or `'MicrosoftEdge'`. The web engine attaches over CDP, so the browser must be Chromium-based; any other value fails the config. |
| `browserVersion` | `'latest'` | Any version TestMu AI offers for the platform, such as `'140'`. |
| `platform` | `'Windows 11'` | A TestMu AI desktop platform, such as `'Windows 10'` or `'macOS Sequoia'`. |
| `project` | `'e2e'` | Sent as `LT:Options.project`, as [`testmu()`](/integrations/testmu) sends its own. |
| `build` | the run id | The dashboard build the sessions are grouped under. The same default as `testmu()`'s, so a run's device and browser sessions share one build. |
| `sessionName` | `e2e-<run id>-<target>-<slot or attempt id>` | Every session's dashboard name. A given name gets `-<slot>` appended when the target has more than one worker slot, and `-<attempt id>` in `attempt` scope, so each session has its own. |
| `geoLocation` | none | Country the browser's IP geolocates to, as a code TestMu AI takes: `'US'`, `'FR'`. |
| `timezone` | none | The machine's time zone, as TestMu AI takes it: `'UTC+05:30'`. |
| `route` | `'/puppeteer'` | The hub route that serves the session's CDP endpoint. See [Routes](#routes). |
| `hub` | `'cdp.lambdatest.com'` | The hub host, without a scheme or path. |
| `capabilities` | none | Further `LT:Options` capabilities, such as `video`, `network`, `console`, `idleTimeout`, `resolution`, or `tunnel` and `tunnelName` for [TestMu AI Tunnel](https://www.lambdatest.com/support/docs/testing-locally-hosted-pages/). `idleTimeout` is 600 seconds when absent. `user`, `accessKey`, `build`, `name`, `project`, `platform`, `geoLocation`, `timezone`, `browserName`, `browserVersion`, and a nested `LT:Options` fail the config here: the provider sets them from the environment and the options above. |

```ts
testmuBrowsers({
scope: 'attempt',
platform: 'Windows 10',
build: `checkout ${process.env.GITHUB_SHA}`,
capabilities: { video: true, network: true, idleTimeout: 300 },
})
```

A misspelled option, a `route` other than the two below, or a `hub` with a
scheme or path fails with `INVALID_CONFIG` when the config loads.

## Scopes

`scope: 'worker'` keeps one session per worker slot for the whole run and
gives every attempt a new browser context in it, as a local run does: a test
never sees another test's cookies, storage, or permissions. TestMu AI shows
it as one session, named after the run, the target, and the slot, that covers every
test the worker ran.

`scope: 'attempt'` starts a session per attempt, so each test is its own
TestMu AI session, named after the run, the target, and the attempt. It costs a
session start per test and carries the limits of a
[per-attempt lease](/browser#hosted-browsers): no `headers`, `basicAuth`, or
`userAgent`, no `app.clearState()`, and no [sessions](/authentication). A
dropped connection cannot be resumed. The engine reconnects to the same CDP
URL, which starts a new TestMu AI session under the same name; the engine
sees a different browser, closes it, and fails the attempt with
`CDP recovery failed`. That second session shows in the dashboard and counts
against your plan while it lasts.

## Routes

A TestMu AI session starts when its CDP websocket opens, on one of the
hub's routes:

- `'/puppeteer'` (default) serves a raw CDP endpoint on every TestMu AI
machine. The dashboard labels these sessions Puppeteer.
- `'/playwright-cdp'` labels sessions as Playwright, but serves raw CDP only on
machines that run TestMu AI's newest backend, which is still rolling out. On
a machine without it the session fails to connect, so keep the default
unless you need the Playwright label.

## Limits

- **60-second connect.** The web engine waits 60 seconds for the browser to
accept the CDP connection, and `launchTimeout` does not change that. A
session still in TestMu AI's queue, or one that is slow to provision, fails
its attempt with `connectOverCDP: Timeout 60000ms exceeded`
(`LAUNCH_TIMEOUT`). Chrome on Windows typically connects in about 10 to 30
seconds; Edge sessions can take longer than the limit.
- **About 24 to 30 workers per target in `worker` scope.** The engine hands
every worker's CDP URL to the workers in one environment variable capped at
16 KB, and each URL carries its capabilities. Past about 30 workers with the
defaults, or 24 with several `capabilities`, the run fails at start with
`ENGINE_FAILURE: the browser leases take ... bytes`. `scope: 'attempt'` leases in each worker and
has no such ceiling.

## Recordings

TestMu AI records the session's screen itself when you pass
`capabilities: { video: true }`; watch it in the TestMu AI dashboard. The
provider does not hand that recording to e2e, so an attempt that records
[video](/reference/config#video) gets the web engine's screencast of the
page, as a local run does.

## Downloads

TestMu AI's browsers save downloads on their own machines, and the provider
has no way to read them back, so `browser.waitForDownload` fails with
`ENGINE_FAILURE` naming the provider. Assert on the download link or the
request instead.

## What the provider does

- Builds the session's CDP URL with the capabilities above and returns it as
the lease. It calls no TestMu AI API: opening the websocket starts the
session and the engine closing it ends the session, so `release` has
nothing to call.
- Names every session as `testmu()` does, `e2e-<run id>-<target>-<slot or
attempt id>` unless `sessionName` is given, in the `build`, sends `project`
as `LT:Options.project`, and
logs `TestMu AI session "<name>" in build "<build>"` to the reporter as
each lease is made.
- Reads `LT_USERNAME` and `LT_ACCESS_KEY` from the run's environment and puts
them only in the CDP URL: they never appear in a log line. A missing or
blank one fails the lease with an error naming it, such as
`LT_ACCESS_KEY is not set`; in `worker` scope that ends the run before any
test.
- Sets `idleTimeout` to 600 seconds unless `capabilities` sets one: the hub
otherwise ends a session after 300 seconds without browser traffic, which a
slow step between two browser calls can exceed. The same timeout ends a
session a dead worker held.
Loading
Loading