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. |