diff --git a/.changeset/testmuai-browsers.md b/.changeset/testmuai-browsers.md
new file mode 100644
index 000000000..9bb0df4a2
--- /dev/null
+++ b/.changeset/testmuai-browsers.md
@@ -0,0 +1,5 @@
+---
+"@e2e-dev/testmuai": minor
+---
+
+`@e2e-dev/testmuai`: `web({ browser: testmuai() })` runs a web target in TestMu AI's hosted Chrome or Edge on Windows and macOS, one session per worker slot, or per attempt with `scope: 'attempt'`. It builds the session's CDP URL from `browserName`, `browserVersion`, `platform`, `build`, and further `LT:Options` `capabilities`, names each session after its target and its slot or attempt inside one build per run, reads `LT_USERNAME` and `LT_ACCESS_KEY` from the run's environment and keeps them out of every log line, and serves raw CDP from the hub's `/puppeteer` route by default, with `/playwright-cdp` as an option. A TestMu AI session starts when its websocket opens and ends when it closes, so it calls no API and has no SDK; `@e2e-dev/web` is its peer.
diff --git a/AGENTS.md b/AGENTS.md
index 6340a3daf..d2bcad922 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,6 +72,11 @@ suites that consume the built packages the way a user would.
engine's provider seam (`BrowserProvider` for web, `DeviceProvider` for
mobile) and imports the engine's types only; the engines never know it
exists.
+- `packages/testmuai` - the published `@e2e-dev/testmuai` package:
+ TestMu AI hosted Chrome and Edge for the web engine (`BrowserProvider`).
+ A TestMu AI session starts when its CDP websocket opens and ends when it
+ closes, so the provider only builds the URL: no SDK, no API call, and
+ `@e2e-dev/web` is its only peer.
- `packages/eas` - the published `@e2e-dev/eas` package: EAS Simulators
hosted iOS simulators and Android emulators for the mobile engine
(`DeviceProvider`). Expo publishes no SDK for the sessions API, so it calls
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cc2969edf..90f8f9309 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -254,5 +254,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/testmuai`, `@e2e-dev/eas`) does
the same on the engines it plugs into; do not make it mutual or narrow it.
diff --git a/README.md b/README.md
index 9ebbc17c1..5c4bc3914 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ config and an example test. The
| [`@e2e-dev/mobile`](https://www.npmjs.com/package/@e2e-dev/mobile) | iOS and Android engine: simulators and emulators through agent-device. |
| [`@e2e-dev/github`](https://www.npmjs.com/package/@e2e-dev/github) | Reporter that posts results as a pull request comment. |
| [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. |
+| [`@e2e-dev/testmuai`](https://www.npmjs.com/package/@e2e-dev/testmuai) | TestMu AI hosted Chrome and Edge 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. |
## Documentation
diff --git a/docs/browser.mdx b/docs/browser.mdx
index bf4e4a5b6..fe3dc5bd8 100644
--- a/docs/browser.mdx
+++ b/docs/browser.mdx
@@ -158,8 +158,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/testmuai) it is `testmuai()` from
+`@e2e-dev/testmuai`, 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](/viewports#fill-the-window) instead of a fixed 1280 by
720 area of it.
diff --git a/docs/docs.json b/docs/docs.json
index 19b432cee..737dea72a 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -203,6 +203,7 @@
"pages": [
"integrations/index",
"integrations/kernel",
+ "integrations/testmuai",
"integrations/eas"
]
},
diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx
index 10f027b96..6fdf36261 100644
--- a/docs/integrations/index.mdx
+++ b/docs/integrations/index.mdx
@@ -13,6 +13,9 @@ integration decides where it comes from.
`@e2e-dev/kernel`: hosted Chromium for web targets, one browser per worker or per test.
+
+ `@e2e-dev/testmuai`: hosted Chrome and Edge on Windows and macOS for web targets, one session per worker or per test.
+
`@e2e-dev/eas`: hosted iOS simulators and Android emulators for mobile targets.
diff --git a/docs/integrations/testmuai.mdx b/docs/integrations/testmuai.mdx
new file mode 100644
index 000000000..ed7fd0459
--- /dev/null
+++ b/docs/integrations/testmuai.mdx
@@ -0,0 +1,159 @@
+---
+title: TestMu AI browsers
+sidebarTitle: TestMu AI
+description: Run web targets in TestMu AI's hosted Chrome and Edge with the testmuai() browser provider from @e2e-dev/testmuai.
+---
+
+`testmuai()` from `@e2e-dev/testmuai` is a
+[browser provider](/browser#hosted-browsers) for
+[TestMu AI](https://www.lambdatest.com). 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
+
+
+```bash npm
+npm install --save-dev @e2e-dev/testmuai
+```
+
+```bash pnpm
+pnpm add -D @e2e-dev/testmuai
+```
+
+```bash bun
+bun add -d @e2e-dev/testmuai
+```
+
+
+```ts title="e2e.config.ts"
+import type { E2EConfig } from 'e2e';
+import { web } from '@e2e-dev/web';
+import { testmuai } from '@e2e-dev/testmuai';
+
+export default {
+ targets: [
+ {
+ name: 'testmuai',
+ engine: web({ browser: testmuai(), viewport: null }),
+ app: { url: 'https://staging.example.com' },
+ },
+ ],
+} satisfies E2EConfig;
+```
+
+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'`. |
+| `build` | `'e2e '` | The TestMu AI build the sessions are grouped under. |
+| `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`, and `platform` fail the config here: the provider sets them from the environment and the options above. |
+
+```ts
+testmuai({
+ 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 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 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 and works with every
+ account today. TestMu AI's dashboard labels these sessions Puppeteer.
+- `'/playwright-cdp'` is TestMu AI's route for Playwright's
+ `connectOverCDP`. Its sessions are labelled Playwright. Use it once
+ TestMu AI serves raw CDP on it for your account.
+
+## 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 a platform slow to provision (Edge on
+ macOS can take close to a minute), fails its attempt with
+ `connectOverCDP: Timeout 60000ms exceeded`. Windows with Chrome or Edge
+ connects well inside it.
+- **About 25 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 25 workers (fewer with
+ many `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 `e2e ` inside the build, and
+ logs `TestMu AI session "" in 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 `LT_USERNAME and LT_ACCESS_KEY must be 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.
diff --git a/docs/reference/environment.mdx b/docs/reference/environment.mdx
index 767e5d6d3..58ce7c28a 100644
--- a/docs/reference/environment.mdx
+++ b/docs/reference/environment.mdx
@@ -101,6 +101,7 @@ What is sent and how to read it is on the [Telemetry](/telemetry) page.
| --- | --- | --- |
| `PLAYWRIGHT_BROWSERS_PATH` | value | Playwright's browser cache, honored by the engine's `prepare` hook |
| `KERNEL_API_KEY` | value | The API key `kernel()` from `@e2e-dev/kernel` creates browsers with; unset, the browser lease fails. See [Kernel](/integrations/kernel) |
+| `LT_USERNAME`, `LT_ACCESS_KEY` | value | The TestMu AI username and access key `testmuai()` from `@e2e-dev/testmuai` starts sessions with, from Account Settings → Password & Security → Username and Access Key on your TestMu AI account; either unset, the browser lease fails. See [TestMu AI](/integrations/testmuai) |
| `EXPO_TOKEN` | value | The Expo access token `easSimulators()` from `@e2e-dev/eas` starts and stops simulator sessions with; unset, it uses the eas-cli login in `.expo/state.json` under `HOME` (`USERPROFILE` on Windows), and with neither the device lease fails. See [EAS Simulators](/integrations/eas) |
| `HOME`, `USERPROFILE` | value | The home `easSimulators()` finds the eas-cli login under when `EXPO_TOKEN` is unset: `USERPROFILE` on Windows, `HOME` elsewhere, the OS user's home when unset |
| `E2E_AGENT_DEVICE_POOL__` | value | Written by `@e2e-dev/mobile` in `prepare` and read by each worker to find its booted device. Not meant to be set by hand |
diff --git a/docs/security.mdx b/docs/security.mdx
index e225804d0..41d3a8045 100644
--- a/docs/security.mdx
+++ b/docs/security.mdx
@@ -264,6 +264,9 @@ Built-in features may connect to:
- the GitHub API, only from the opt-in `@e2e-dev/github` reporter
- the Kernel API and its hosted browsers, only from
[`kernel()`](/integrations/kernel)
+- TestMu AI's CDP hub and its hosted browsers, only from
+ [`testmuai()`](/integrations/testmuai). The username and access key
+ travel in the session's CDP URL and are never logged
- the Expo API and the simulators' agent-device daemons, only from
[`easSimulators()`](/integrations/eas). It authenticates with `EXPO_TOKEN`,
else with the eas-cli login in `~/.expo/state.json`: on a CI runner, set
diff --git a/package.json b/package.json
index 4d515218b..209056b54 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
],
"type": "module",
"scripts": {
- "build": "pnpm --filter e2e run build && pnpm --filter @e2e-dev/web run build && pnpm --filter @e2e-dev/mobile run build && pnpm --filter @e2e-dev/github run build && pnpm --filter @e2e-dev/kernel run build && pnpm --filter @e2e-dev/eas run build",
+ "build": "pnpm --filter e2e run build && pnpm --filter @e2e-dev/web run build && pnpm --filter @e2e-dev/mobile run build && pnpm --filter @e2e-dev/github run build && pnpm --filter @e2e-dev/kernel run build && pnpm --filter @e2e-dev/testmuai run build && pnpm --filter @e2e-dev/eas run build",
"check": "pnpm run lint && pnpm run check:dead-code && pnpm run typecheck && pnpm run docs:check-errors && pnpm run check:peer-ranges && pnpm run docs:check",
"check:dead-code": "fallow dead-code",
"check:peer-ranges": "node scripts/check-peer-ranges.ts",
@@ -24,7 +24,7 @@
"canary": "node scripts/canary-changeset.ts && changeset version --snapshot canary && pnpm run build",
"canary:publish": "changeset publish --tag canary --no-git-tag && node scripts/restore-peer-ranges.ts",
"typecheck": "pnpm run build && pnpm -r run typecheck && tsc -p scripts",
- "test": "pnpm run build && pnpm --filter e2e run test && pnpm --filter @e2e-dev/web run test && pnpm --filter @e2e-dev/mobile run test && pnpm --filter @e2e-dev/github run test && pnpm --filter @e2e-dev/kernel run test && pnpm --filter @e2e-dev/eas run test && pnpm run test:scripts",
+ "test": "pnpm run build && pnpm --filter e2e run test && pnpm --filter @e2e-dev/web run test && pnpm --filter @e2e-dev/mobile run test && pnpm --filter @e2e-dev/github run test && pnpm --filter @e2e-dev/kernel run test && pnpm --filter @e2e-dev/testmuai run test && pnpm --filter @e2e-dev/eas run test && pnpm run test:scripts",
"test:testbed": "pnpm run build && pnpm --filter @e2e-dev/testbed test",
"test:web-benchmark": "pnpm run build && pnpm --filter @e2e-dev/web-benchmark test",
"test:mobile-benchmark": "pnpm run build && pnpm --filter @e2e-dev/mobile-benchmark test",
diff --git a/packages/testmuai/LICENSE b/packages/testmuai/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/packages/testmuai/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/testmuai/NOTICE b/packages/testmuai/NOTICE
new file mode 100644
index 000000000..9ab16f21f
--- /dev/null
+++ b/packages/testmuai/NOTICE
@@ -0,0 +1,5 @@
+@e2e-dev/testmuai
+Copyright 2026 TesterArmy, Inc.
+
+This product includes software developed at TesterArmy, Inc.
+(https://tester.army).
diff --git a/packages/testmuai/README.md b/packages/testmuai/README.md
new file mode 100644
index 000000000..2aaf251f4
--- /dev/null
+++ b/packages/testmuai/README.md
@@ -0,0 +1,38 @@
+# @e2e-dev/testmuai
+
+[TestMu AI](https://www.lambdatest.com) hosted browsers for [`e2e`](https://www.npmjs.com/package/e2e):
+`web({ browser: testmuai() })` runs a web target in TestMu AI's hosted
+Chrome and Edge on Windows and macOS.
+
+## Install
+
+```bash
+npm install --save-dev @e2e-dev/testmuai
+```
+
+## Usage
+
+```ts title="e2e.config.ts"
+import type { E2EConfig } from 'e2e';
+import { web } from '@e2e-dev/web';
+import { testmuai } from '@e2e-dev/testmuai';
+
+export default {
+ targets: [
+ {
+ name: 'testmuai',
+ engine: web({ browser: testmuai({ platform: 'Windows 11' }), viewport: null }),
+ app: { url: 'https://staging.example.com' },
+ },
+ ],
+} satisfies E2EConfig;
+```
+
+Set `LT_USERNAME` and `LT_ACCESS_KEY` in the environment `e2e run` starts in.
+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)).
+
+The [TestMu AI integration page](https://e2e.tester.army/docs/integrations/testmuai)
+covers the options, scopes, routes, recordings, and downloads.
diff --git a/packages/testmuai/package.json b/packages/testmuai/package.json
new file mode 100644
index 000000000..9cf5b7634
--- /dev/null
+++ b/packages/testmuai/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@e2e-dev/testmuai",
+ "version": "0.0.0",
+ "description": "TestMu AI hosted browsers for e2e: run web targets in TestMu AI's cloud Chrome and Edge",
+ "keywords": [
+ "e2e",
+ "end-to-end",
+ "testing",
+ "ai-testing",
+ "testmuai",
+ "cloud-browsers",
+ "hosted-browsers",
+ "browser-automation",
+ "chromium"
+ ],
+ "license": "Apache-2.0",
+ "contributors": [
+ "Sahil Saw "
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/tester-army/e2e.git",
+ "directory": "packages/testmuai"
+ },
+ "bugs": {
+ "url": "https://github.com/tester-army/e2e/issues"
+ },
+ "homepage": "https://github.com/tester-army/e2e/tree/main/packages/testmuai#readme",
+ "publishConfig": {
+ "registry": "https://registry.npmjs.org/",
+ "access": "public",
+ "tag": "latest"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "NOTICE"
+ ],
+ "scripts": {
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json && node ../../scripts/stamp-dist.ts",
+ "prepublishOnly": "node ../../scripts/check-dist.ts",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:unit": "vitest run tests/unit"
+ },
+ "peerDependencies": {
+ "@e2e-dev/web": ">=0.11.0 <1",
+ "e2e": ">=0.15.0 <1"
+ },
+ "devDependencies": {
+ "@e2e-dev/web": "workspace:*",
+ "@types/node": "26.6.2",
+ "e2e": "workspace:*",
+ "typescript": "7.0.2",
+ "vitest": "5.0.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
diff --git a/packages/testmuai/src/env.ts b/packages/testmuai/src/env.ts
new file mode 100644
index 000000000..4048f018e
--- /dev/null
+++ b/packages/testmuai/src/env.ts
@@ -0,0 +1,5 @@
+/** A non-empty variable from the run's environment, trimmed, or `undefined`. */
+export function envValue(env: Readonly>, name: string): string | undefined {
+ const value = env[name]?.trim();
+ return value === undefined || value === '' ? undefined : value;
+}
diff --git a/packages/testmuai/src/index.ts b/packages/testmuai/src/index.ts
new file mode 100644
index 000000000..d0409479f
--- /dev/null
+++ b/packages/testmuai/src/index.ts
@@ -0,0 +1,9 @@
+/**
+ * `@e2e-dev/testmuai` public surface: `testmuai()`, a browser provider
+ * that runs `@e2e-dev/web` targets in TestMu AI's hosted Chrome and Edge.
+ * TestMu AI creates a session when its CDP websocket is opened, so the
+ * provider needs no SDK and makes no API calls.
+ */
+
+export { testmuai } from './provider.ts';
+export type { TestMuAIBrowser, TestMuAIOptions, TestMuAIRoute } from './provider.ts';
diff --git a/packages/testmuai/src/provider.ts b/packages/testmuai/src/provider.ts
new file mode 100644
index 000000000..2385ddf0a
--- /dev/null
+++ b/packages/testmuai/src/provider.ts
@@ -0,0 +1,144 @@
+/** TestMu AI's hosted Chrome and Edge as a `BrowserProvider` for the web engine. */
+
+import type { BrowserLease, BrowserProvider, BrowserProviderScope, BrowserRequest } from '@e2e-dev/web';
+import { ConfigurationError, rejectUnknownKeys } from 'e2e/engine';
+import { envValue } from './env.ts';
+
+const LT_USERNAME = 'LT_USERNAME';
+const LT_ACCESS_KEY = 'LT_ACCESS_KEY';
+
+/**
+ * The TestMu AI route that serves a raw CDP endpoint. `/puppeteer` serves it
+ * today. `/playwright-cdp` is TestMu AI's route for Playwright's
+ * `connectOverCDP` clients and labels sessions as Playwright; use it once
+ * TestMu AI serves raw CDP on it for your account.
+ */
+export type TestMuAIRoute = '/puppeteer' | '/playwright-cdp';
+
+/** The browsers the web engine can attach to over CDP: Chromium ones. */
+export type TestMuAIBrowser = 'Chrome' | 'MicrosoftEdge';
+
+export interface TestMuAIOptions {
+ /**
+ * `worker` (default): one TestMu AI session per worker slot for the run.
+ * `attempt`: a fresh session per test attempt, so each test is its own
+ * TestMu AI session; rules out `headers`, `basicAuth`, and `userAgent`.
+ */
+ readonly scope?: BrowserProviderScope | undefined;
+ /** CDP route on the hub; `/puppeteer` when absent. */
+ readonly route?: TestMuAIRoute | undefined;
+ /** The hub host, without a scheme or path; `cdp.lambdatest.com` when absent. */
+ readonly hub?: string | undefined;
+ /** `Chrome` when absent. */
+ readonly browserName?: TestMuAIBrowser | undefined;
+ /** `latest` when absent. */
+ readonly browserVersion?: string | undefined;
+ /** TestMu AI platform name, such as `Windows 11` (default) or `macOS Sequoia`. */
+ readonly platform?: string | undefined;
+ /** TestMu AI build name; `e2e ` when absent, so one run is one build. */
+ readonly build?: string | undefined;
+ /**
+ * Further `LT:Options` capabilities (`video`, `network`, `console`,
+ * `idleTimeout`, `tunnel`, ...). `idleTimeout` is 600 seconds when absent.
+ * `user`, `accessKey`, `build`, `name`, and `platform` are not accepted
+ * here: the provider sets them.
+ */
+ readonly capabilities?: Readonly> | undefined;
+}
+
+const OPTION_KEYS: readonly string[] = Object.keys({
+ scope: true,
+ route: true,
+ hub: true,
+ browserName: true,
+ browserVersion: true,
+ platform: true,
+ build: true,
+ capabilities: true,
+} satisfies Record);
+
+const ROUTES: readonly string[] = ['/puppeteer', '/playwright-cdp'] satisfies TestMuAIRoute[];
+const BROWSERS: readonly string[] = ['Chrome', 'MicrosoftEdge'] satisfies TestMuAIBrowser[];
+const PROVIDER_CAPABILITIES = ['user', 'accessKey', 'build', 'name', 'platform'];
+const HOST = /^[A-Za-z0-9.-]+(:\d+)?$/;
+
+const DEFAULT_HUB = 'cdp.lambdatest.com';
+const DEFAULT_ROUTE: TestMuAIRoute = '/puppeteer';
+// The hub ends a CDP session after 300 s without client traffic, shorter than a slow test's gaps.
+const DEFAULT_IDLE_TIMEOUT_SECONDS = 600;
+
+/**
+ * TestMu AI browsers for `web({ browser: testmuai() })`. A TestMu AI
+ * session is its websocket: connecting to the CDP URL starts it and closing
+ * the connection ends it, so `acquire` only builds the URL and `release` has
+ * nothing to call. Every session is named after the target and the slot or
+ * attempt, inside one build per run. `LT_USERNAME` and `LT_ACCESS_KEY` come
+ * from the run's environment and never appear in a log line.
+ */
+export function testmuai(options: TestMuAIOptions = {}): BrowserProvider {
+ if (!isRecord(options)) {
+ throw new ConfigurationError('INVALID_CONFIG', 'testmuai() options must be an object');
+ }
+ rejectUnknownKeys('testmuai()', options, OPTION_KEYS);
+ const { scope, route = DEFAULT_ROUTE, hub = DEFAULT_HUB, browserName = 'Chrome' } = options;
+ if (!ROUTES.includes(route)) {
+ throw new ConfigurationError('INVALID_CONFIG', `testmuai: \`route\` must be one of ${ROUTES.join(', ')}, got "${route}"`);
+ }
+ if (!HOST.test(hub)) {
+ throw new ConfigurationError('INVALID_CONFIG', `testmuai: \`hub\` must be a host such as "${DEFAULT_HUB}", without a scheme or path, got "${hub}"`);
+ }
+ if (!BROWSERS.includes(browserName)) {
+ throw new ConfigurationError('INVALID_CONFIG', `testmuai: \`browserName\` must be one of ${BROWSERS.join(', ')}, got "${browserName}"`);
+ }
+ if (options.capabilities !== undefined && !isRecord(options.capabilities)) {
+ throw new ConfigurationError('INVALID_CONFIG', 'testmuai: `capabilities` must be an object of `LT:Options` fields');
+ }
+ const reserved = PROVIDER_CAPABILITIES.filter((key) => options.capabilities !== undefined && key in options.capabilities);
+ if (reserved.length > 0) {
+ throw new ConfigurationError(
+ 'INVALID_CONFIG',
+ `testmuai: \`capabilities\` cannot set ${reserved.map((key) => `\`${key}\``).join(', ')}; use the \`build\` and \`platform\` options, and LT_USERNAME/LT_ACCESS_KEY for credentials`,
+ );
+ }
+ return {
+ name: 'testmuai',
+ ...(scope === undefined ? {} : { scope }),
+ async acquire(request: BrowserRequest): Promise {
+ const user = envValue(request.env, LT_USERNAME);
+ const accessKey = envValue(request.env, LT_ACCESS_KEY);
+ if (user === undefined || accessKey === undefined) {
+ throw new Error(`${LT_USERNAME} and ${LT_ACCESS_KEY} must be set`);
+ }
+ const label = request.attemptId ?? `slot ${request.slot + 1} of ${request.slots}`;
+ const build = options.build ?? `e2e ${request.runId}`;
+ const name = `e2e ${request.targetName} ${label}`;
+ const capabilities = {
+ browserName,
+ browserVersion: options.browserVersion ?? 'latest',
+ 'LT:Options': {
+ idleTimeout: DEFAULT_IDLE_TIMEOUT_SECONDS,
+ ...options.capabilities,
+ platform: options.platform ?? 'Windows 11',
+ build,
+ name,
+ user,
+ accessKey,
+ },
+ };
+ request.log(`TestMu AI session "${name}" in build "${build}"`);
+ return {
+ // Worker-scope leases share one bounded environment variable, so the id stays short.
+ id: `${request.targetName}:${label}`,
+ cdpEndpoint: `wss://${hub}${route}?capabilities=${encodeURIComponent(JSON.stringify(capabilities))}`,
+ };
+ },
+ async release(): Promise {
+ // Closing the CDP connection, which the engine does, ends the TestMu AI session.
+ },
+ };
+}
+
+/** True for a plain object. Config runs as JavaScript, so the types alone are no guard. */
+function isRecord(value: unknown): value is object {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/packages/testmuai/tests/unit/testmuai.test.ts b/packages/testmuai/tests/unit/testmuai.test.ts
new file mode 100644
index 000000000..1db9631f3
--- /dev/null
+++ b/packages/testmuai/tests/unit/testmuai.test.ts
@@ -0,0 +1,177 @@
+/**
+ * `testmuai()` builds the CDP URL a TestMu AI session starts on: the
+ * capabilities it encodes, credentials read from the run's environment
+ * only, session and build names, the route and hub options, scope, option
+ * validation, log lines that never carry the access key, and a release that
+ * calls nothing.
+ */
+
+import type { BrowserReleaseContext, BrowserRequest } from '@e2e-dev/web';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { testmuai, type TestMuAIOptions } from '../../src/index.ts';
+
+const ACCESS_KEY = 'LT_secret-access-key';
+
+interface Capabilities {
+ browserName: string;
+ browserVersion: string;
+ 'LT:Options': Record;
+}
+
+function request(overrides: Partial = {}): BrowserRequest & { lines: string[] } {
+ const lines: string[] = [];
+ return {
+ runId: 'run-1',
+ targetName: 'chromium',
+ slot: 0,
+ slots: 2,
+ env: { LT_USERNAME: 'alice', LT_ACCESS_KEY: ACCESS_KEY },
+ signal: new AbortController().signal,
+ log: (line: string) => lines.push(line),
+ lines,
+ ...overrides,
+ };
+}
+
+function decode(cdpEndpoint: string): { url: URL; capabilities: Capabilities } {
+ const url = new URL(cdpEndpoint);
+ return { url, capabilities: JSON.parse(url.searchParams.get('capabilities') ?? '{}') as Capabilities };
+}
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', () => {
+ throw new Error('testmuai() must not call the network');
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+});
+
+describe('testmuai()', () => {
+ it('builds a /puppeteer CDP URL on cdp.lambdatest.com with the run and target in the names', async () => {
+ const lease = await testmuai().acquire(request());
+ const { url, capabilities } = decode(lease.cdpEndpoint);
+
+ expect(url.protocol).toBe('wss:');
+ expect(url.host).toBe('cdp.lambdatest.com');
+ expect(url.pathname).toBe('/puppeteer');
+ expect(capabilities).toEqual({
+ browserName: 'Chrome',
+ browserVersion: 'latest',
+ 'LT:Options': {
+ idleTimeout: 600,
+ platform: 'Windows 11',
+ build: 'e2e run-1',
+ name: 'e2e chromium slot 1 of 2',
+ user: 'alice',
+ accessKey: ACCESS_KEY,
+ },
+ });
+ expect(lease.id).toBe('chromium:slot 1 of 2');
+ });
+
+ it('names a per-attempt session after the attempt', async () => {
+ const provider = testmuai({ scope: 'attempt' });
+ const lease = await provider.acquire(request({ attemptId: 'attempt-7' }));
+
+ expect(provider.scope).toBe('attempt');
+ expect(decode(lease.cdpEndpoint).capabilities['LT:Options']['name']).toBe('e2e chromium attempt-7');
+ expect(lease.id).toBe('chromium:attempt-7');
+ });
+
+ it('leaves scope to the engine default when none is given', () => {
+ expect('scope' in testmuai()).toBe(false);
+ });
+
+ it('honours the route, hub, browser, platform, build, and extra capabilities', async () => {
+ const lease = await testmuai({
+ route: '/playwright-cdp',
+ hub: 'cdp.eu.example.test',
+ browserName: 'MicrosoftEdge',
+ browserVersion: '140',
+ platform: 'macOS Sequoia',
+ build: 'nightly',
+ capabilities: { video: true, idleTimeout: 300 },
+ }).acquire(request());
+ const { url, capabilities } = decode(lease.cdpEndpoint);
+
+ expect(url.host).toBe('cdp.eu.example.test');
+ expect(url.pathname).toBe('/playwright-cdp');
+ expect(capabilities.browserName).toBe('MicrosoftEdge');
+ expect(capabilities.browserVersion).toBe('140');
+ expect(capabilities['LT:Options']).toMatchObject({ platform: 'macOS Sequoia', build: 'nightly', video: true, idleTimeout: 300 });
+ });
+
+ it('takes the credentials from the run environment, not process.env', async () => {
+ vi.stubEnv('LT_USERNAME', 'from-process-env');
+ vi.stubEnv('LT_ACCESS_KEY', 'from-process-env');
+ const options = decode((await testmuai().acquire(request())).cdpEndpoint).capabilities['LT:Options'];
+
+ expect(options['user']).toBe('alice');
+ expect(options['accessKey']).toBe(ACCESS_KEY);
+ });
+
+ it('fails the lease when a credential is missing or blank', async () => {
+ await expect(testmuai().acquire(request({ env: { LT_USERNAME: 'alice' } }))).rejects.toThrow(
+ 'LT_USERNAME and LT_ACCESS_KEY must be set',
+ );
+ await expect(testmuai().acquire(request({ env: { LT_USERNAME: ' ', LT_ACCESS_KEY: ACCESS_KEY } }))).rejects.toThrow(
+ 'LT_USERNAME and LT_ACCESS_KEY must be set',
+ );
+ });
+
+ it.each([
+ [{ platfrom: 'macOS Sequoia' }, 'testmuai() has unknown key "platfrom"; did you mean "platform"?'],
+ [{ route: 'playwright-cdp' }, '`route` must be one of /puppeteer, /playwright-cdp'],
+ [{ hub: 'https://cdp.lambdatest.com' }, '`hub` must be a host'],
+ [{ hub: 'cdp.lambdatest.com/puppeteer' }, '`hub` must be a host'],
+ [{ browserName: 'Firefox' }, '`browserName` must be one of Chrome, MicrosoftEdge'],
+ [{ capabilities: { user: 'mallory', accessKey: 'spoofed' } }, '`capabilities` cannot set `user`, `accessKey`'],
+ [{ capabilities: { build: 'nightly', platform: 'Windows 10' } }, '`capabilities` cannot set `build`, `platform`'],
+ [null, 'testmuai() options must be an object'],
+ [[], 'testmuai() options must be an object'],
+ [{ capabilities: null }, '`capabilities` must be an object'],
+ [{ capabilities: ['video'] }, '`capabilities` must be an object'],
+ [{ capabilities: 'video' }, '`capabilities` must be an object'],
+ ])('rejects %j when the provider is created', (options, message) => {
+ expect(() => testmuai(options as unknown as TestMuAIOptions)).toThrow(
+ expect.objectContaining({ code: 'INVALID_CONFIG', message: expect.stringContaining(message) }),
+ );
+ });
+
+ it('keeps 25 worker-scope leases inside the engine\'s 16 KB hand-off', async () => {
+ const provider = testmuai({ capabilities: { video: true, network: true, console: true, tunnel: true, tunnelName: 'ci-tunnel' } });
+ const runId = '01a0fcab-e8da-797a-8b35-2168b81385c0';
+ const env = { LT_USERNAME: 'some.user.name', LT_ACCESS_KEY: `LT_${'x'.repeat(46)}` };
+ const leases = await Promise.all(
+ Array.from({ length: 25 }, (_, slot) => provider.acquire(request({ runId, targetName: 'checkout-web', slot, slots: 25, env }))),
+ );
+
+ expect(Buffer.byteLength(JSON.stringify({ slots: 25, leases }))).toBeLessThan(16 * 1024);
+ });
+
+ it('logs the session and build names but never the access key or the URL', async () => {
+ const req = request();
+ await testmuai().acquire(req);
+
+ expect(req.lines).toEqual(['TestMu AI session "e2e chromium slot 1 of 2" in build "e2e run-1"']);
+ expect(req.lines.join('\n')).not.toContain(ACCESS_KEY);
+ expect(req.lines.join('\n')).not.toContain('wss://');
+ });
+
+ it('releases without calling anything', async () => {
+ const provider = testmuai();
+ const lease = await provider.acquire(request());
+ const context: BrowserReleaseContext = {
+ runId: 'run-1',
+ targetName: 'chromium',
+ env: {},
+ signal: new AbortController().signal,
+ log: () => {},
+ };
+
+ await expect(provider.release(lease, context)).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/testmuai/tsconfig.build.json b/packages/testmuai/tsconfig.build.json
new file mode 100644
index 000000000..22269d818
--- /dev/null
+++ b/packages/testmuai/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "noEmit": false,
+ "declaration": true,
+ "sourceMap": true,
+ "declarationMap": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/testmuai/tsconfig.json b/packages/testmuai/tsconfig.json
new file mode 100644
index 000000000..635a4f631
--- /dev/null
+++ b/packages/testmuai/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": ".",
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/testmuai/vitest.config.ts b/packages/testmuai/vitest.config.ts
new file mode 100644
index 000000000..4059568af
--- /dev/null
+++ b/packages/testmuai/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+/** Pure unit tests: the provider only builds a URL, so nothing touches the network. */
+export default defineConfig({
+ test: {
+ include: ['tests/unit/**/*.test.ts'],
+ testTimeout: 30_000,
+ pool: 'forks',
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2944f7cd2..d32bea7fc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -470,6 +470,24 @@ importers:
specifier: 5.0.1
version: 5.0.1(@types/node@26.6.2)(vite@8.1.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@1.21.7)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0))
+ packages/testmuai:
+ devDependencies:
+ '@e2e-dev/web':
+ specifier: workspace:*
+ version: link:../web
+ '@types/node':
+ specifier: 26.6.2
+ version: 26.6.2
+ e2e:
+ specifier: workspace:*
+ version: link:../e2e
+ typescript:
+ specifier: 7.0.2
+ version: 7.0.2
+ vitest:
+ specifier: 5.0.1
+ version: 5.0.1(@types/node@26.6.2)(vite@8.1.5(@types/node@26.6.2)(esbuild@0.28.2)(jiti@1.21.7)(terser@5.51.2)(tsx@4.23.14)(yaml@2.9.0))
+
packages/web:
devDependencies:
'@types/node':
diff --git a/skills/e2e/references/setup.md b/skills/e2e/references/setup.md
index 7c42c1e01..28e8cfd71 100644
--- a/skills/e2e/references/setup.md
+++ b/skills/e2e/references/setup.md
@@ -162,7 +162,7 @@ start a script that brings them up and serves the app.
| Option | Meaning |
| --- | --- |
-| `browser` | `'chromium'` (default), `'firefox'`, `'webkit'`, or a `BrowserProvider` leasing hosted browsers over CDP (`kernel()` from `@e2e-dev/kernel`, or your own), which implies chromium and excludes `connect`. Scope `'worker'` (default): one browser per worker slot from `prepare` to `finish`; `'attempt'`: one per attempt, with `reconnectEndpoint`'s limits. |
+| `browser` | `'chromium'` (default), `'firefox'`, `'webkit'`, or a `BrowserProvider` leasing hosted browsers over CDP (`kernel()` from `@e2e-dev/kernel`, `testmuai()` from `@e2e-dev/testmuai`, or your own), which implies chromium and excludes `connect`. Scope `'worker'` (default): one browser per worker slot from `prepare` to `finish`; `'attempt'`: one per attempt, with `reconnectEndpoint`'s limits. |
| `viewport` | `{ width, height }`, default 1280x720; `null` follows the browser window (hosted live view, headed run). On a headed hosted browser (Kernel) use `null` and size the service's screen; a fixed size gives a smaller, unmaximized window. |
| `connect` | `{ cdpEndpoint }` attaches to a remote Chromium over CDP; both it and `reconnectEndpoint` are resolvers `(signal) => url`, not strings. With `reconnectEndpoint` it rides one persistent default context and reconnects only to the original browser and page. |
| `headers` | Sent to the app's site only (Vercel's `x-vercel-protection-bypass`, ngrok's `ngrok-skip-browser-warning`), `agent.act` included; disables the browser HTTP cache and service workers. |