From 92612d52da870f9e95f5d9b1a33eda49b3de48af Mon Sep 17 00:00:00 2001 From: SahilSawLT Date: Fri, 2 Oct 2026 18:14:44 +0530 Subject: [PATCH 1/4] feat(testmuai): @e2e-dev/testmuai, TestMu AI hosted browsers for the web engine testmuai() is a BrowserProvider for @e2e-dev/web that leases TestMu AI (formerly LambdaTest) hosted Chrome and Edge on Windows and macOS. A session starts when its CDP websocket opens, so the provider only builds the CDP URL from the target's options and LT_USERNAME/LT_ACCESS_KEY in the run's environment; it calls no API and release is a no-op. One session per worker slot by default, or per attempt with scope: 'attempt'. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KoNxaN1YAAP9PwmHtMm1WX --- .changeset/testmuai-browsers.md | 5 + AGENTS.md | 5 + CONTRIBUTING.md | 2 +- README.md | 1 + docs/docs.json | 1 + docs/integrations/index.mdx | 3 + docs/integrations/testmuai.mdx | 129 +++++++++++ docs/reference/environment.mdx | 1 + docs/security.mdx | 3 + package.json | 4 +- packages/testmuai/LICENSE | 202 ++++++++++++++++++ packages/testmuai/NOTICE | 5 + packages/testmuai/README.md | 34 +++ packages/testmuai/package.json | 67 ++++++ packages/testmuai/src/env.ts | 5 + packages/testmuai/src/index.ts | 9 + packages/testmuai/src/provider.ts | 91 ++++++++ packages/testmuai/tests/unit/testmuai.test.ts | 140 ++++++++++++ packages/testmuai/tsconfig.build.json | 13 ++ packages/testmuai/tsconfig.json | 9 + packages/testmuai/vitest.config.ts | 10 + pnpm-lock.yaml | 18 ++ skills/e2e/references/setup.md | 2 +- 23 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 .changeset/testmuai-browsers.md create mode 100644 docs/integrations/testmuai.mdx create mode 100644 packages/testmuai/LICENSE create mode 100644 packages/testmuai/NOTICE create mode 100644 packages/testmuai/README.md create mode 100644 packages/testmuai/package.json create mode 100644 packages/testmuai/src/env.ts create mode 100644 packages/testmuai/src/index.ts create mode 100644 packages/testmuai/src/provider.ts create mode 100644 packages/testmuai/tests/unit/testmuai.test.ts create mode 100644 packages/testmuai/tsconfig.build.json create mode 100644 packages/testmuai/tsconfig.json create mode 100644 packages/testmuai/vitest.config.ts 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/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..c54ca8e3a --- /dev/null +++ b/docs/integrations/testmuai.mdx @@ -0,0 +1,129 @@ +--- +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. Each worker gets its own session, so `workers` sets how +many run at once; keep it within your TestMu AI plan's parallel sessions, +since a session beyond it waits in TestMu AI's queue. + +## 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. | +| `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. | +| `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/). `user` and `accessKey` always come from the environment. | + +```ts +testmuai({ + scope: 'attempt', + platform: 'macOS Sequoia', + build: `checkout ${process.env.GITHUB_SHA}`, + capabilities: { video: true, network: true, idleTimeout: 300 }, +}) +``` + +## 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: reconnecting would start a new +TestMu AI session, so the attempt fails instead. + +## 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. + +## 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. +- Leaves a session a dead worker held to TestMu AI's idle timeout + (`capabilities: { idleTimeout }`, in seconds), which ends it. diff --git a/docs/reference/environment.mdx b/docs/reference/environment.mdx index 767e5d6d3..f8c6af170 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; 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..65324f342 --- /dev/null +++ b/packages/testmuai/README.md @@ -0,0 +1,34 @@ +# @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. + +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..4952370aa --- /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 { 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..539a163fd --- /dev/null +++ b/packages/testmuai/src/provider.ts @@ -0,0 +1,91 @@ +/** 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 { 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'; + +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; `cdp.lambdatest.com` when absent. */ + readonly hub?: string | undefined; + /** `Chrome` (default) or `MicrosoftEdge`: CDP needs a Chromium browser. */ + readonly browserName?: string | 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`, ...). `user` and `accessKey` always come from + * the run's environment. + */ + readonly capabilities?: Readonly> | undefined; +} + +const DEFAULT_HUB = 'cdp.lambdatest.com'; +const DEFAULT_ROUTE: TestMuAIRoute = '/puppeteer'; + +/** + * 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 { + const { scope, route = DEFAULT_ROUTE, hub = DEFAULT_HUB } = options; + 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: options.browserName ?? 'Chrome', + browserVersion: options.browserVersion ?? 'latest', + 'LT:Options': { + platform: options.platform ?? 'Windows 11', + ...options.capabilities, + build, + name, + user, + accessKey, + }, + }; + request.log(`TestMu AI session "${name}" in build "${build}"`); + return { + id: `${request.runId}:${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. + }, + }; +} diff --git a/packages/testmuai/tests/unit/testmuai.test.ts b/packages/testmuai/tests/unit/testmuai.test.ts new file mode 100644 index 000000000..da8480af7 --- /dev/null +++ b/packages/testmuai/tests/unit/testmuai.test.ts @@ -0,0 +1,140 @@ +/** + * `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, 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 } from '../../src/index.ts'; + +const ACCESS_KEY = 'LT_secret-access-key'; + +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: Record } { + const url = new URL(cdpEndpoint); + return { url, capabilities: JSON.parse(url.searchParams.get('capabilities') ?? '{}') }; +} + +beforeEach(() => { + vi.stubGlobal('fetch', () => { + throw new Error('testmuai() must not call the network'); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +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': { + platform: 'Windows 11', + build: 'e2e run-1', + name: 'e2e chromium slot 1 of 2', + user: 'alice', + accessKey: ACCESS_KEY, + }, + }); + expect(lease.id).toBe('run-1: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('run-1: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, never from capabilities or process.env', async () => { + vi.stubEnv('LT_USERNAME', 'from-process-env'); + vi.stubEnv('LT_ACCESS_KEY', 'from-process-env'); + const lease = await testmuai({ capabilities: { user: 'mallory', accessKey: 'spoofed' } }).acquire(request()); + const options = decode(lease.cdpEndpoint).capabilities['LT:Options']; + + expect(options.user).toBe('alice'); + expect(options.accessKey).toBe(ACCESS_KEY); + vi.unstubAllEnvs(); + }); + + 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('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..fb259198e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,6 +439,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.14)(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/mobile: dependencies: agent-device: 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. | From bb727961af6ddd9b062250cf35c2cb3de4b86827 Mon Sep 17 00:00:00 2001 From: SahilSawLT Date: Fri, 2 Oct 2026 18:34:18 +0530 Subject: [PATCH 2/4] fix(testmuai): validate options, default idleTimeout, document limits - testmuai() rejects unknown keys, a route other than the two routes, a hub with a scheme or path, a non-Chromium browserName, and capabilities that would override user, accessKey, build, name or platform, with INVALID_CONFIG at config load instead of a failed attempt. - idleTimeout defaults to 600 s; the hub otherwise ends a session after 300 s without browser traffic. - Lease ids drop the run id: worker-scope leases share the engine's 16 KB hand-off. Docs state the ~25-worker ceiling, the 60 s connect cap, and what a dropped attempt-scope connection does; browser.mdx lists the provider; the lockfile importer is regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KoNxaN1YAAP9PwmHtMm1WX --- docs/browser.mdx | 5 +- docs/integrations/testmuai.mdx | 45 ++++++++++---- packages/testmuai/src/index.ts | 2 +- packages/testmuai/src/provider.ts | 60 ++++++++++++++++--- packages/testmuai/tests/unit/testmuai.test.ts | 60 ++++++++++++++----- pnpm-lock.yaml | 36 +++++------ 6 files changed, 154 insertions(+), 54 deletions(-) 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/integrations/testmuai.mdx b/docs/integrations/testmuai.mdx index c54ca8e3a..5b7fb0c17 100644 --- a/docs/integrations/testmuai.mdx +++ b/docs/integrations/testmuai.mdx @@ -45,31 +45,36 @@ export default { Set `LT_USERNAME` and `LT_ACCESS_KEY` in the environment `e2e run` starts in, such as CI secrets. Each worker gets its own session, so `workers` sets how -many run at once; keep it within your TestMu AI plan's parallel sessions, -since a session beyond it waits in TestMu AI's queue. +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. | +| `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. | -| `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/). `user` and `accessKey` always come from the environment. | +| `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: 'macOS Sequoia', + 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 @@ -83,8 +88,11 @@ 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: reconnecting would start a new -TestMu AI session, so the attempt fails instead. +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 @@ -97,6 +105,21 @@ hub's routes: `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 @@ -125,5 +148,7 @@ request instead. 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. -- Leaves a session a dead worker held to TestMu AI's idle timeout - (`capabilities: { idleTimeout }`, in seconds), which ends it. +- 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/packages/testmuai/src/index.ts b/packages/testmuai/src/index.ts index 4952370aa..d0409479f 100644 --- a/packages/testmuai/src/index.ts +++ b/packages/testmuai/src/index.ts @@ -6,4 +6,4 @@ */ export { testmuai } from './provider.ts'; -export type { TestMuAIOptions, TestMuAIRoute } 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 index 539a163fd..520732e5e 100644 --- a/packages/testmuai/src/provider.ts +++ b/packages/testmuai/src/provider.ts @@ -1,6 +1,7 @@ /** 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'; @@ -14,6 +15,9 @@ const LT_ACCESS_KEY = 'LT_ACCESS_KEY'; */ 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. @@ -23,10 +27,10 @@ export interface TestMuAIOptions { readonly scope?: BrowserProviderScope | undefined; /** CDP route on the hub; `/puppeteer` when absent. */ readonly route?: TestMuAIRoute | undefined; - /** The hub host; `cdp.lambdatest.com` when absent. */ + /** The hub host, without a scheme or path; `cdp.lambdatest.com` when absent. */ readonly hub?: string | undefined; - /** `Chrome` (default) or `MicrosoftEdge`: CDP needs a Chromium browser. */ - readonly browserName?: 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`. */ @@ -35,14 +39,33 @@ export interface TestMuAIOptions { readonly build?: string | undefined; /** * Further `LT:Options` capabilities (`video`, `network`, `console`, - * `idleTimeout`, `tunnel`, ...). `user` and `accessKey` always come from - * the run's environment. + * `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 @@ -53,7 +76,24 @@ const DEFAULT_ROUTE: TestMuAIRoute = '/puppeteer'; * from the run's environment and never appear in a log line. */ export function testmuai(options: TestMuAIOptions = {}): BrowserProvider { - const { scope, route = DEFAULT_ROUTE, hub = DEFAULT_HUB } = options; + 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}"`); + } + 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 }), @@ -67,11 +107,12 @@ export function testmuai(options: TestMuAIOptions = {}): BrowserProvider { const build = options.build ?? `e2e ${request.runId}`; const name = `e2e ${request.targetName} ${label}`; const capabilities = { - browserName: options.browserName ?? 'Chrome', + browserName, browserVersion: options.browserVersion ?? 'latest', 'LT:Options': { - platform: options.platform ?? 'Windows 11', + idleTimeout: DEFAULT_IDLE_TIMEOUT_SECONDS, ...options.capabilities, + platform: options.platform ?? 'Windows 11', build, name, user, @@ -80,7 +121,8 @@ export function testmuai(options: TestMuAIOptions = {}): BrowserProvider { }; request.log(`TestMu AI session "${name}" in build "${build}"`); return { - id: `${request.runId}:${request.targetName}:${label}`, + // 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))}`, }; }, diff --git a/packages/testmuai/tests/unit/testmuai.test.ts b/packages/testmuai/tests/unit/testmuai.test.ts index da8480af7..bf33ab401 100644 --- a/packages/testmuai/tests/unit/testmuai.test.ts +++ b/packages/testmuai/tests/unit/testmuai.test.ts @@ -1,16 +1,23 @@ /** * `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, log lines - * that never carry the access key, and a release that calls nothing. + * 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 } from '../../src/index.ts'; +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 { @@ -26,9 +33,9 @@ function request(overrides: Partial = {}): BrowserRequest & { li }; } -function decode(cdpEndpoint: string): { url: URL; capabilities: Record } { +function decode(cdpEndpoint: string): { url: URL; capabilities: Capabilities } { const url = new URL(cdpEndpoint); - return { url, capabilities: JSON.parse(url.searchParams.get('capabilities') ?? '{}') }; + return { url, capabilities: JSON.parse(url.searchParams.get('capabilities') ?? '{}') as Capabilities }; } beforeEach(() => { @@ -39,6 +46,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe('testmuai()', () => { @@ -53,6 +61,7 @@ describe('testmuai()', () => { browserName: 'Chrome', browserVersion: 'latest', 'LT:Options': { + idleTimeout: 600, platform: 'Windows 11', build: 'e2e run-1', name: 'e2e chromium slot 1 of 2', @@ -60,7 +69,7 @@ describe('testmuai()', () => { accessKey: ACCESS_KEY, }, }); - expect(lease.id).toBe('run-1:chromium:slot 1 of 2'); + expect(lease.id).toBe('chromium:slot 1 of 2'); }); it('names a per-attempt session after the attempt', async () => { @@ -68,8 +77,8 @@ describe('testmuai()', () => { 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('run-1:chromium:attempt-7'); + 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', () => { @@ -95,15 +104,13 @@ describe('testmuai()', () => { expect(capabilities['LT:Options']).toMatchObject({ platform: 'macOS Sequoia', build: 'nightly', video: true, idleTimeout: 300 }); }); - it('takes the credentials from the run environment, never from capabilities or process.env', async () => { + 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 lease = await testmuai({ capabilities: { user: 'mallory', accessKey: 'spoofed' } }).acquire(request()); - const options = decode(lease.cdpEndpoint).capabilities['LT:Options']; + const options = decode((await testmuai().acquire(request())).cdpEndpoint).capabilities['LT:Options']; - expect(options.user).toBe('alice'); - expect(options.accessKey).toBe(ACCESS_KEY); - vi.unstubAllEnvs(); + expect(options['user']).toBe('alice'); + expect(options['accessKey']).toBe(ACCESS_KEY); }); it('fails the lease when a credential is missing or blank', async () => { @@ -115,6 +122,31 @@ describe('testmuai()', () => { ); }); + 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`'], + ])('rejects %j when the provider is created', (options, message) => { + expect(() => testmuai(options 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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb259198e..d32bea7fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,24 +439,6 @@ 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.14)(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/mobile: dependencies: agent-device: @@ -488,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': From 9aea7a64a41b9f6078e2166e02e20731812f5a24 Mon Sep 17 00:00:00 2001 From: SahilSawLT Date: Fri, 2 Oct 2026 18:45:14 +0530 Subject: [PATCH 3/4] fix(testmuai): report non-object options and capabilities as INVALID_CONFIG A JavaScript config can pass null, an array, or a primitive where the types say object: testmuai(null) and capabilities: null threw a raw TypeError, and arrays were accepted. Both now fail with INVALID_CONFIG. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KoNxaN1YAAP9PwmHtMm1WX --- packages/testmuai/src/provider.ts | 11 +++++++++++ packages/testmuai/tests/unit/testmuai.test.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/testmuai/src/provider.ts b/packages/testmuai/src/provider.ts index 520732e5e..2385ddf0a 100644 --- a/packages/testmuai/src/provider.ts +++ b/packages/testmuai/src/provider.ts @@ -76,6 +76,9 @@ const DEFAULT_IDLE_TIMEOUT_SECONDS = 600; * 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)) { @@ -87,6 +90,9 @@ export function testmuai(options: TestMuAIOptions = {}): BrowserProvider { 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( @@ -131,3 +137,8 @@ export function testmuai(options: TestMuAIOptions = {}): BrowserProvider { }, }; } + +/** 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 index bf33ab401..1db9631f3 100644 --- a/packages/testmuai/tests/unit/testmuai.test.ts +++ b/packages/testmuai/tests/unit/testmuai.test.ts @@ -130,8 +130,13 @@ describe('testmuai()', () => { [{ 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 TestMuAIOptions)).toThrow( + expect(() => testmuai(options as unknown as TestMuAIOptions)).toThrow( expect.objectContaining({ code: 'INVALID_CONFIG', message: expect.stringContaining(message) }), ); }); From 3a538479d974cf2a90c5f4419aca1154fef1ba09 Mon Sep 17 00:00:00 2001 From: SahilSawLT Date: Fri, 2 Oct 2026 18:56:37 +0530 Subject: [PATCH 4/4] docs(testmuai): say where to get LT_USERNAME and LT_ACCESS_KEY Sign up or log in to TestMu AI and copy the username and access key from Account Settings > Password & Security. Added to the integration page, the package README, and the environment reference. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KoNxaN1YAAP9PwmHtMm1WX --- docs/integrations/testmuai.mdx | 7 ++++++- docs/reference/environment.mdx | 2 +- packages/testmuai/README.md | 4 ++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/integrations/testmuai.mdx b/docs/integrations/testmuai.mdx index 5b7fb0c17..ed7fd0459 100644 --- a/docs/integrations/testmuai.mdx +++ b/docs/integrations/testmuai.mdx @@ -44,7 +44,12 @@ export default { ``` Set `LT_USERNAME` and `LT_ACCESS_KEY` in the environment `e2e run` starts in, -such as CI secrets. Each worker gets its own session, so `workers` sets how +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 diff --git a/docs/reference/environment.mdx b/docs/reference/environment.mdx index f8c6af170..58ce7c28a 100644 --- a/docs/reference/environment.mdx +++ b/docs/reference/environment.mdx @@ -101,7 +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; either unset, the browser lease fails. See [TestMu AI](/integrations/testmuai) | +| `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/packages/testmuai/README.md b/packages/testmuai/README.md index 65324f342..2aaf251f4 100644 --- a/packages/testmuai/README.md +++ b/packages/testmuai/README.md @@ -29,6 +29,10 @@ export default { ``` 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.