From fe91e5fe181f3e56350c4a6c496d1e0ff042c695 Mon Sep 17 00:00:00 2001 From: SahilSawLT Date: Mon, 5 Oct 2026 20:02:08 +0530 Subject: [PATCH] feat(testmu): hosted Chrome and Edge for the web engine with testmuBrowsers() @e2e-dev/testmu/web exports testmuBrowsers(), a BrowserProvider for TestMu AI (formerly LambdaTest) hosted Chrome and Edge on Windows and macOS. A session starts when its CDP websocket opens and ends when the engine closes it, so the provider only builds the CDP URL from the options and LT_USERNAME / LT_ACCESS_KEY (via the package's testmuCredentials()); it calls no API. - One session per worker slot, or per attempt with scope: 'attempt'. - route /puppeteer (default, raw CDP on every machine) or /playwright-cdp (Playwright label; raw CDP only where TestMu AI's newest backend has rolled out). hub cdp.lambdatest.com. - Sessions are named as testmu()'s: build (default the run id, so a run's device and browser sessions share one build) and sessionName (default e2e---); project (default e2e) is sent as LT:Options.project. geoLocation and timezone are options, as on testmu(). - idleTimeout defaults to 600 s. Unknown options, out-of-range values, empty or null strings, and capabilities that set what the provider sets (including browserName, browserVersion and a nested LT:Options) fail with INVALID_CONFIG at config load. - The package root stays device-only; the browser provider lives at @e2e-dev/testmu/web, and @e2e-dev/mobile, @e2e-dev/web and agent-device are optional peers, so neither side's imports or types reach the other. - Docs: integrations/testmu-browsers page with a checked example (docs/examples/web/testmu-browsers.config.ts), nav, card, env, security, browser.mdx, README, AGENTS, skill row; credentials sourcing on both pages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KoNxaN1YAAP9PwmHtMm1WX --- .changeset/testmu-browsers.md | 5 + AGENTS.md | 7 + README.md | 2 +- docs/browser.mdx | 5 +- docs/docs.json | 3 +- docs/examples/web/testmu-browsers.config.ts | 13 ++ docs/integrations/index.mdx | 3 + docs/integrations/testmu-browsers.mdx | 171 ++++++++++++++++ docs/integrations/testmu.mdx | 4 + docs/reference/environment.mdx | 2 +- docs/security.mdx | 3 + packages/testmu/README.md | 40 +++- packages/testmu/package.json | 22 +- packages/testmu/src/browsers.ts | 189 +++++++++++++++++ packages/testmu/src/index.ts | 2 + packages/testmu/src/web.ts | 8 + packages/testmu/tests/unit/browsers.test.ts | 212 ++++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/check-docs-examples.ts | 1 + skills/e2e/references/setup.md | 2 +- 20 files changed, 689 insertions(+), 8 deletions(-) create mode 100644 .changeset/testmu-browsers.md create mode 100644 docs/examples/web/testmu-browsers.config.ts create mode 100644 docs/integrations/testmu-browsers.mdx create mode 100644 packages/testmu/src/browsers.ts create mode 100644 packages/testmu/src/web.ts create mode 100644 packages/testmu/tests/unit/browsers.test.ts diff --git a/.changeset/testmu-browsers.md b/.changeset/testmu-browsers.md new file mode 100644 index 000000000..9d02bc331 --- /dev/null +++ b/.changeset/testmu-browsers.md @@ -0,0 +1,5 @@ +--- +"@e2e-dev/testmu": minor +--- + +`@e2e-dev/testmu/web`: `web({ browser: testmuBrowsers() })` runs a web target in TestMu AI (formerly LambdaTest) hosted Chrome or Edge on Windows and macOS, one session per worker slot, or one per attempt with `scope: 'attempt'`. A session starts when its CDP websocket opens and ends when the engine closes it, so the provider only builds the CDP URL (`hub` `cdp.lambdatest.com`) from `browserName`, `browserVersion`, `platform`, `geoLocation`, `timezone`, and further `LT:Options` `capabilities`, with `LT_USERNAME` and `LT_ACCESS_KEY` from the run's environment, which never appear in a log line. `route` is `/puppeteer` by default, which serves raw CDP on every machine; `/playwright-cdp` labels sessions as Playwright but serves raw CDP only on machines with TestMu AI's newest backend, which is still rolling out. Sessions are named as `testmu()`'s are: `build` defaults to the run id, so a run's device and browser sessions share one build, `sessionName` to `e2e---`, and `project` (default `e2e`) is sent as `LT:Options.project`. `idleTimeout` defaults to 600 seconds. Unknown options, a `scope` or `route` outside their values, a `hub` with a scheme or path, a non-Chromium `browserName`, an empty or non-string string option, `null` for any option, and `capabilities` that set what the provider sets (`user`, `accessKey`, `build`, `name`, `project`, `platform`, `geoLocation`, `timezone`, `browserName`, `browserVersion`, or a nested `LT:Options`) fail with `INVALID_CONFIG` at config load. The package root stays device-only, and `@e2e-dev/mobile`, `@e2e-dev/web`, and `agent-device` are optional peers, so a project installs only the side it uses. diff --git a/AGENTS.md b/AGENTS.md index 4f761c8c8..493187f2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,13 @@ suites that consume the built packages the way a user would. cloud provider is the vendor client: the package allocates agent-device leases from a daemon it starts per run, so `agent-device` (which must be the same copy `@e2e-dev/mobile` uses) and `@e2e-dev/mobile` are its peers. + `@e2e-dev/testmu/web` exports `testmuBrowsers()`, TestMu AI hosted Chrome + and Edge for the web engine (`BrowserProvider`): a session starts when its + CDP websocket opens and ends when it closes, so it only builds the URL and + calls no API. The root stays device-only, so neither side's types or + imports reach the other. Every + peer (`@e2e-dev/mobile`, `@e2e-dev/web`, `agent-device`) is optional, so a + project installs only the side it uses. - `apps/testbed` (`@e2e-dev/testbed`, private) — dogfood project that consumes the **built** packages like a real user would: the playground app where every runner feature (sessions, routes, downloads, frames, uploads, diff --git a/README.md b/README.md index 65bf73b19..ee0caf714 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ config and an example test. The | [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. | | [`@e2e-dev/eas`](https://www.npmjs.com/package/@e2e-dev/eas) | EAS Simulators hosted iOS simulators and Android emulators for the mobile engine. | | [`@e2e-dev/decision`](https://e2e.tester.army/docs/decision-models) | Decision-model executors for bounded semantic actions and assertions. | -| [`@e2e-dev/testmu`](https://www.npmjs.com/package/@e2e-dev/testmu) | TestMu AI (formerly LambdaTest) hosted Android emulators, iOS simulators, and real devices for the mobile engine. | +| [`@e2e-dev/testmu`](https://www.npmjs.com/package/@e2e-dev/testmu) | TestMu AI (formerly LambdaTest) hosted Android emulators, iOS simulators, and real devices for the mobile engine, and hosted Chrome and Edge for the web engine (`@e2e-dev/testmu/web`). | ## Documentation diff --git a/docs/browser.mdx b/docs/browser.mdx index da6bf58df..1079652bb 100644 --- a/docs/browser.mdx +++ b/docs/browser.mdx @@ -194,8 +194,9 @@ lacks: it asks for a browser when one is needed and gives it back when its scope ends, on every exit path, so a session billed by the minute stops when the run no longer uses it. The engine knows no vendor: for [Kernel](/integrations/kernel), `kernel()` from `@e2e-dev/kernel` is the -provider, and for any other service a provider is a few dozen lines in your -project. Add `viewport: null` when +provider, for [TestMu AI](/integrations/testmu-browsers), +`testmuBrowsers()` from `@e2e-dev/testmu/web` is the provider, and for any other +service a provider is a few dozen lines in your project. Add `viewport: null` when the service shows the window in a live view: the page then [fills the window](/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 30906c169..cbe567e77 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -210,7 +210,8 @@ "integrations/index", "integrations/kernel", "integrations/eas", - "integrations/testmu" + "integrations/testmu", + "integrations/testmu-browsers" ] }, { diff --git a/docs/examples/web/testmu-browsers.config.ts b/docs/examples/web/testmu-browsers.config.ts new file mode 100644 index 000000000..535f23796 --- /dev/null +++ b/docs/examples/web/testmu-browsers.config.ts @@ -0,0 +1,13 @@ +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { testmuBrowsers } from '@e2e-dev/testmu/web'; + +export default { + targets: [ + { + name: 'testmu-web', + engine: web({ browser: testmuBrowsers(), viewport: null }), + app: { url: 'https://staging.example.com' }, + }, + ], +} satisfies E2EConfig; diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index b6cebe12a..838815e40 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -19,6 +19,9 @@ integration decides where it comes from. `@e2e-dev/testmu`: hosted Android emulators, iOS simulators, and real devices for mobile targets. + + `@e2e-dev/testmu`: hosted Chrome and Edge on Windows and macOS for web targets, one session per worker or per test. + A service without an integration is one small provider in your project: diff --git a/docs/integrations/testmu-browsers.mdx b/docs/integrations/testmu-browsers.mdx new file mode 100644 index 000000000..697a5ee65 --- /dev/null +++ b/docs/integrations/testmu-browsers.mdx @@ -0,0 +1,171 @@ +--- +title: TestMu AI browsers +sidebarTitle: TestMu AI browsers +description: Run web targets in TestMu AI's hosted Chrome and Edge with the testmuBrowsers() browser provider from @e2e-dev/testmu/web. +--- + +`testmuBrowsers()` from `@e2e-dev/testmu/web` is a +[browser provider](/browser#hosted-browsers) for +[TestMu AI](https://www.lambdatest.com) (formerly LambdaTest). By default each worker slot gets its +own TestMu AI session for the run; `scope: 'attempt'` gives every test +attempt its own session instead. Tests and CI stay the same; the config +changes only in the target's `browser`. + +## Setup + + +```bash npm +npm install --save-dev @e2e-dev/testmu +``` + +```bash pnpm +pnpm add -D @e2e-dev/testmu +``` + +```bash bun +bun add -d @e2e-dev/testmu +``` + + +```ts title="e2e.config.ts" +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { testmuBrowsers } from '@e2e-dev/testmu/web'; + +export default { + targets: [ + { + name: 'testmu-web', + engine: web({ browser: testmuBrowsers(), viewport: null }), + app: { url: 'https://staging.example.com' }, + }, + ], +} satisfies E2EConfig; +``` + +The browser provider lives at `@e2e-dev/testmu/web`, so a web-only project +needs neither `@e2e-dev/mobile` nor agent-device. The package root exports +the [device provider](/integrations/testmu), which uses both. + +Set `LT_USERNAME` and `LT_ACCESS_KEY` in the environment `e2e run` starts in, +such as CI secrets. To get them, [sign up for TestMu AI](https://accounts.lambdatest.com/register) +or log in to your account, then copy your username and access key from +**Account Settings → Password & Security → Username and Access Key** +([accounts.lambdatest.com/security/username-accesskey](https://accounts.lambdatest.com/security/username-accesskey)). + +Each worker gets its own session, so `workers` sets how +many run at once. Keep it within your TestMu AI plan's parallel sessions: +a session beyond it waits in TestMu AI's queue, and the web engine gives a +session 60 seconds to accept the CDP connection, so one that waits longer +fails its attempt. See [Limits](#limits). + +## Options + +| Option | Default | Meaning | +| ------ | ------- | ------- | +| `scope` | `'worker'` | `'worker'`: one session per worker slot for the run. `'attempt'`: a fresh session per test attempt, retries included. See [Scopes](#scopes). | +| `browserName` | `'Chrome'` | `'Chrome'` or `'MicrosoftEdge'`. The web engine attaches over CDP, so the browser must be Chromium-based; any other value fails the config. | +| `browserVersion` | `'latest'` | Any version TestMu AI offers for the platform, such as `'140'`. | +| `platform` | `'Windows 11'` | A TestMu AI desktop platform, such as `'Windows 10'` or `'macOS Sequoia'`. | +| `project` | `'e2e'` | Sent as `LT:Options.project`, as [`testmu()`](/integrations/testmu) sends its own. | +| `build` | the run id | The dashboard build the sessions are grouped under. The same default as `testmu()`'s, so a run's device and browser sessions share one build. | +| `sessionName` | `e2e---` | Every session's dashboard name. A given name gets `-` appended when the target has more than one worker slot, and `-` in `attempt` scope, so each session has its own. | +| `geoLocation` | none | Country the browser's IP geolocates to, as a code TestMu AI takes: `'US'`, `'FR'`. | +| `timezone` | none | The machine's time zone, as TestMu AI takes it: `'UTC+05:30'`. | +| `route` | `'/puppeteer'` | The hub route that serves the session's CDP endpoint. See [Routes](#routes). | +| `hub` | `'cdp.lambdatest.com'` | The hub host, without a scheme or path. | +| `capabilities` | none | Further `LT:Options` capabilities, such as `video`, `network`, `console`, `idleTimeout`, `resolution`, or `tunnel` and `tunnelName` for [TestMu AI Tunnel](https://www.lambdatest.com/support/docs/testing-locally-hosted-pages/). `idleTimeout` is 600 seconds when absent. `user`, `accessKey`, `build`, `name`, `project`, `platform`, `geoLocation`, `timezone`, `browserName`, `browserVersion`, and a nested `LT:Options` fail the config here: the provider sets them from the environment and the options above. | + +```ts +testmuBrowsers({ + scope: 'attempt', + platform: 'Windows 10', + build: `checkout ${process.env.GITHUB_SHA}`, + capabilities: { video: true, network: true, idleTimeout: 300 }, +}) +``` + +A misspelled option, a `route` other than the two below, or a `hub` with a +scheme or path fails with `INVALID_CONFIG` when the config loads. + +## Scopes + +`scope: 'worker'` keeps one session per worker slot for the whole run and +gives every attempt a new browser context in it, as a local run does: a test +never sees another test's cookies, storage, or permissions. TestMu AI shows +it as one session, named after the run, the target, and the slot, that covers every +test the worker ran. + +`scope: 'attempt'` starts a session per attempt, so each test is its own +TestMu AI session, named after the run, the target, and the attempt. It costs a +session start per test and carries the limits of a +[per-attempt lease](/browser#hosted-browsers): no `headers`, `basicAuth`, or +`userAgent`, no `app.clearState()`, and no [sessions](/authentication). A +dropped connection cannot be resumed. The engine reconnects to the same CDP +URL, which starts a new TestMu AI session under the same name; the engine +sees a different browser, closes it, and fails the attempt with +`CDP recovery failed`. That second session shows in the dashboard and counts +against your plan while it lasts. + +## Routes + +A TestMu AI session starts when its CDP websocket opens, on one of the +hub's routes: + +- `'/puppeteer'` (default) serves a raw CDP endpoint on every TestMu AI + machine. The dashboard labels these sessions Puppeteer. +- `'/playwright-cdp'` labels sessions as Playwright, but serves raw CDP only on + machines that run TestMu AI's newest backend, which is still rolling out. On + a machine without it the session fails to connect, so keep the default + unless you need the Playwright label. + +## Limits + +- **60-second connect.** The web engine waits 60 seconds for the browser to + accept the CDP connection, and `launchTimeout` does not change that. A + session still in TestMu AI's queue, or one that is slow to provision, fails + its attempt with `connectOverCDP: Timeout 60000ms exceeded` + (`LAUNCH_TIMEOUT`). Chrome on Windows typically connects in about 10 to 30 + seconds; Edge sessions can take longer than the limit. +- **About 24 to 30 workers per target in `worker` scope.** The engine hands + every worker's CDP URL to the workers in one environment variable capped at + 16 KB, and each URL carries its capabilities. Past about 30 workers with the + defaults, or 24 with several `capabilities`, the run fails at start with + `ENGINE_FAILURE: the browser leases take ... bytes`. `scope: 'attempt'` leases in each worker and + has no such ceiling. + +## Recordings + +TestMu AI records the session's screen itself when you pass +`capabilities: { video: true }`; watch it in the TestMu AI dashboard. The +provider does not hand that recording to e2e, so an attempt that records +[video](/reference/config#video) gets the web engine's screencast of the +page, as a local run does. + +## Downloads + +TestMu AI's browsers save downloads on their own machines, and the provider +has no way to read them back, so `browser.waitForDownload` fails with +`ENGINE_FAILURE` naming the provider. Assert on the download link or the +request instead. + +## What the provider does + +- Builds the session's CDP URL with the capabilities above and returns it as + the lease. It calls no TestMu AI API: opening the websocket starts the + session and the engine closing it ends the session, so `release` has + nothing to call. +- Names every session as `testmu()` does, `e2e---` unless `sessionName` is given, in the `build`, sends `project` + as `LT:Options.project`, 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 an error naming it, such as + `LT_ACCESS_KEY is not set`; in `worker` scope that ends the run before any + test. +- Sets `idleTimeout` to 600 seconds unless `capabilities` sets one: the hub + otherwise ends a session after 300 seconds without browser traffic, which a + slow step between two browser calls can exceed. The same timeout ends a + session a dead worker held. diff --git a/docs/integrations/testmu.mdx b/docs/integrations/testmu.mdx index ba972c80d..0ea55afbd 100644 --- a/docs/integrations/testmu.mdx +++ b/docs/integrations/testmu.mdx @@ -132,6 +132,10 @@ package, not the upload name. Set `LT_USERNAME` and `LT_ACCESS_KEY` to your TestMu AI username and access key in the environment `e2e run` starts in, such as CI secrets. Without either, the lease fails before anything starts, naming the missing one. +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)). agent-device's `testmu` runtime reads the credentials from the environment of the daemon that drives the sessions. The provider starts that daemon from diff --git a/docs/reference/environment.mdx b/docs/reference/environment.mdx index 31f38a206..725e90e21 100644 --- a/docs/reference/environment.mdx +++ b/docs/reference/environment.mdx @@ -109,7 +109,7 @@ What is sent and how to read it is on the [Telemetry](/telemetry) page. | `KERNEL_API_KEY` | value | The API key `kernel()` from `@e2e-dev/kernel` creates browsers with; unset, the browser lease fails. See [Kernel](/integrations/kernel) | | `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 | -| `LT_USERNAME`, `LT_ACCESS_KEY` | value | The TestMu AI username and access key `testmu()` from `@e2e-dev/testmu` leases devices with; it sets them in the runner process's environment only while it calls the agent-device daemon it starts, which reads them from there, and with either unset the device lease fails. See [TestMu AI](/integrations/testmu) | +| `LT_USERNAME`, `LT_ACCESS_KEY` | value | The TestMu AI username and access key `testmu()` from `@e2e-dev/testmu` leases devices with; it sets them in the runner process's environment only while it calls the agent-device daemon it starts, which reads them from there, and with either unset the device lease fails. `testmuBrowsers()` puts them only in the CDP URL of each browser session, and with either unset the browser lease fails. Both come from Account Settings → Password & Security → Username and Access Key on your TestMu AI account. See [TestMu AI](/integrations/testmu) and [TestMu AI browsers](/integrations/testmu-browsers) | | `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 | ## What app processes inherit diff --git a/docs/security.mdx b/docs/security.mdx index 4cf0a0d35..8149fce6e 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -289,6 +289,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 + [`testmuBrowsers()`](/integrations/testmu-browsers). The username and + access key travel only in the CDP URL, never in a log line - 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/packages/testmu/README.md b/packages/testmu/README.md index 78345c534..47d96e820 100644 --- a/packages/testmu/README.md +++ b/packages/testmu/README.md @@ -75,7 +75,45 @@ whole session instead, found by the session's build and name and starting at the session's start time. Runs that overlap and share a fixed `build` and `sessionName` can link each other's videos; the defaults never do. -Full documentation lives at [e2e.tester.army/docs/integrations/testmu](https://e2e.tester.army/docs/integrations/testmu). +## Browsers + +`web({ browser: testmuBrowsers() })` runs a web target in TestMu AI's hosted +Chrome or Edge on Windows and macOS, one session per worker slot, or one per +attempt with `scope: 'attempt'`. It is exported from `@e2e-dev/testmu/web`, +which needs only `@e2e-dev/web`; the package root exports the device +provider. + +```ts title="e2e.config.ts" +import type { E2EConfig } from 'e2e'; +import { web } from '@e2e-dev/web'; +import { testmuBrowsers } from '@e2e-dev/testmu/web'; + +export default { + targets: [ + { + name: 'testmu-web', + engine: web({ browser: testmuBrowsers({ platform: 'Windows 11' }), viewport: null }), + app: { url: 'https://staging.example.com' }, + }, + ], +} satisfies E2EConfig; +``` + +A session starts when its CDP websocket opens and ends when the engine +closes it, so the provider only builds the CDP URL from the options and +`LT_USERNAME`/`LT_ACCESS_KEY`, and calls no API. + +## Credentials + +Both providers read `LT_USERNAME` and `LT_ACCESS_KEY` from the run's +environment. 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)). + +Full documentation lives at [e2e.tester.army/docs/integrations/testmu](https://e2e.tester.army/docs/integrations/testmu) +(devices) and [e2e.tester.army/docs/integrations/testmu-browsers](https://e2e.tester.army/docs/integrations/testmu-browsers) +(browsers). ## License diff --git a/packages/testmu/package.json b/packages/testmu/package.json index 7fe24c378..debb63e89 100644 --- a/packages/testmu/package.json +++ b/packages/testmu/package.json @@ -1,7 +1,7 @@ { "name": "@e2e-dev/testmu", "version": "0.0.0", - "description": "TestMu AI (formerly LambdaTest) for e2e: run mobile targets on hosted Android emulators, iOS simulators, and real devices", + "description": "TestMu AI (formerly LambdaTest) for e2e: run mobile targets on hosted Android emulators, iOS simulators, and real devices, and web targets on hosted Chrome and Edge", "keywords": [ "e2e", "end-to-end", @@ -9,6 +9,9 @@ "mobile-testing", "testmu", "lambdatest", + "browser-testing", + "chrome", + "cdp", "device-cloud", "real-devices", "ios-simulator", @@ -40,6 +43,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./web": { + "types": "./dist/web.d.ts", + "default": "./dist/web.js" } }, "files": [ @@ -56,11 +63,24 @@ }, "peerDependencies": { "@e2e-dev/mobile": ">=0.9.0 <1", + "@e2e-dev/web": ">=0.11.0 <1", "agent-device": ">0.21.20 <1", "e2e": ">=0.15.0 <1" }, + "peerDependenciesMeta": { + "@e2e-dev/mobile": { + "optional": true + }, + "@e2e-dev/web": { + "optional": true + }, + "agent-device": { + "optional": true + } + }, "devDependencies": { "@e2e-dev/mobile": "workspace:*", + "@e2e-dev/web": "workspace:*", "@types/node": "26.6.2", "agent-device": "0.21.20", "e2e": "workspace:*", diff --git a/packages/testmu/src/browsers.ts b/packages/testmu/src/browsers.ts new file mode 100644 index 000000000..d8d8bcf69 --- /dev/null +++ b/packages/testmu/src/browsers.ts @@ -0,0 +1,189 @@ +/** 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 { testmuCredentials } from './credentials.ts'; + +/** + * The hub route that serves a raw CDP endpoint. `/puppeteer` serves it on + * every machine. `/playwright-cdp` labels sessions as Playwright, but serves + * raw CDP only on machines that run TestMu AI's newest backend, which is + * still rolling out; elsewhere the session fails to connect. + */ +export type TestmuBrowsersRoute = '/puppeteer' | '/playwright-cdp'; + +/** The browsers the web engine can attach to over CDP: Chromium ones. */ +export type TestmuBrowserName = 'Chrome' | 'MicrosoftEdge'; + +export interface TestmuBrowsersOptions { + /** + * `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. `/playwright-cdp` is still rolling out (see `TestmuBrowsersRoute`). */ + readonly route?: TestmuBrowsersRoute | undefined; + /** The hub host, without a scheme or path; `cdp.lambdatest.com` when absent. */ + readonly hub?: string | undefined; + /** `Chrome` when absent. */ + readonly browserName?: TestmuBrowserName | undefined; + /** `latest` when absent. */ + readonly browserVersion?: string | undefined; + /** TestMu AI platform name, such as `Windows 11` (default) or `macOS Sequoia`. */ + readonly platform?: string | undefined; + /** Sent as `LT:Options.project`, as `testmu()` sends its own. Defaults to `e2e`. */ + readonly project?: string | undefined; + /** Dashboard build the sessions are grouped under. Defaults to the run id, as `testmu()`'s, so a run's device and browser sessions share one build. */ + readonly build?: string | undefined; + /** + * Name of every session on the dashboard, with `-` (worker scope, + * more than one slot) or `-` (attempt scope) appended so each + * session has its own. Defaults to `e2e---`. Slots count from 1. + */ + readonly sessionName?: string | undefined; + /** Country the browser's IP geolocates to, as a code TestMu AI takes: `US`, `FR`. */ + readonly geoLocation?: string | undefined; + /** The machine's time zone, as TestMu AI takes it: `UTC+05:30`. */ + readonly timezone?: string | undefined; + /** + * Further `LT:Options` capabilities (`video`, `network`, `console`, + * `idleTimeout`, `tunnel`, ...). `idleTimeout` is 600 seconds when absent. + * `user`, `accessKey`, `build`, `name`, `project`, `platform`, + * `geoLocation`, `timezone`, `browserName`, `browserVersion`, and a nested + * `LT:Options` are not accepted here: the provider sets them, from the + * environment and the options above. + */ + readonly capabilities?: Readonly> | undefined; +} + +const OPTION_KEYS: readonly string[] = Object.keys({ + scope: true, + route: true, + hub: true, + browserName: true, + browserVersion: true, + platform: true, + project: true, + build: true, + sessionName: true, + geoLocation: true, + timezone: true, + capabilities: true, +} satisfies Record); + +const SCOPES: readonly string[] = ['worker', 'attempt'] satisfies BrowserProviderScope[]; +const ROUTES: readonly string[] = ['/puppeteer', '/playwright-cdp'] satisfies TestmuBrowsersRoute[]; +const BROWSERS: readonly string[] = ['Chrome', 'MicrosoftEdge'] satisfies TestmuBrowserName[]; +const PROVIDER_CAPABILITIES = ['user', 'accessKey', 'build', 'name', 'project', 'platform', 'geoLocation', 'timezone', 'browserName', 'browserVersion', 'LT:Options']; + +/** The options that are strings when given, checked at config load as `testmu()` checks its own. */ +const OPTIONAL_STRING_KEYS = ['browserVersion', 'platform', 'project', 'build', 'sessionName', 'geoLocation', 'timezone'] as const; + +/** The `LT:Options.project` sent when `project` is absent, as `testmu()`'s default. */ +const DEFAULT_PROJECT = 'e2e'; +const HOST = /^[A-Za-z0-9.-]+(:\d+)?$/; + +const DEFAULT_HUB = 'cdp.lambdatest.com'; +const DEFAULT_ROUTE: TestmuBrowsersRoute = '/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: testmuBrowsers() })`. 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 testmuBrowsers(options: TestmuBrowsersOptions = {}): BrowserProvider { + if (!isRecord(options)) { + throw new ConfigurationError('INVALID_CONFIG', 'testmuBrowsers() options must be an object'); + } + rejectUnknownKeys('testmuBrowsers()', options, OPTION_KEYS); + // Only an absent value takes the default: `null` from a JavaScript config is refused like any other. + const { scope } = options; + const route = options.route === undefined ? DEFAULT_ROUTE : options.route; + const hub = options.hub === undefined ? DEFAULT_HUB : options.hub; + const browserName = options.browserName === undefined ? 'Chrome' : options.browserName; + if (scope !== undefined && !SCOPES.includes(scope)) { + throw new ConfigurationError('INVALID_CONFIG', `testmuBrowsers: \`scope\` must be one of ${SCOPES.join(', ')}, got ${JSON.stringify(scope)}`); + } + if (!ROUTES.includes(route)) { + throw new ConfigurationError('INVALID_CONFIG', `testmuBrowsers: \`route\` must be one of ${ROUTES.join(', ')}, got "${route}"`); + } + if (typeof hub !== 'string' || !HOST.test(hub)) { + throw new ConfigurationError('INVALID_CONFIG', `testmuBrowsers: \`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', `testmuBrowsers: \`browserName\` must be one of ${BROWSERS.join(', ')}, got "${browserName}"`); + } + for (const key of OPTIONAL_STRING_KEYS) { + const value: unknown = options[key]; + if (value !== undefined && (typeof value !== 'string' || value.trim() === '')) { + throw new ConfigurationError('INVALID_CONFIG', `testmuBrowsers: \`${key}\` must be a non-empty string`); + } + } + if (options.capabilities !== undefined && !isRecord(options.capabilities)) { + throw new ConfigurationError('INVALID_CONFIG', 'testmuBrowsers: `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', + `testmuBrowsers: \`capabilities\` cannot set ${reserved.map((key) => `\`${key}\``).join(', ')}; use the options of the same name, and LT_USERNAME/LT_ACCESS_KEY for credentials`, + ); + } + return { + name: 'testmu-browsers', + ...(scope === undefined ? {} : { scope }), + async acquire(request: BrowserRequest): Promise { + const { username, accessKey } = testmuCredentials(request.env); + const label = request.attemptId ?? `slot ${request.slot + 1} of ${request.slots}`; + const build = options.build ?? request.runId; + const name = sessionNameFor(options.sessionName, request); + const capabilities = { + browserName, + browserVersion: options.browserVersion ?? 'latest', + 'LT:Options': { + idleTimeout: DEFAULT_IDLE_TIMEOUT_SECONDS, + ...options.capabilities, + platform: options.platform ?? 'Windows 11', + project: options.project ?? DEFAULT_PROJECT, + build, + name, + ...(options.geoLocation === undefined ? {} : { geoLocation: options.geoLocation }), + ...(options.timezone === undefined ? {} : { timezone: options.timezone }), + user: username, + 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. + }, + }; +} + +/** + * The dashboard name of a session, unique among the run's sessions of the + * target, in `testmu()`'s format: `e2e---` per worker + * slot, or `-` in attempt scope. + */ +function sessionNameFor(sessionName: string | undefined, { runId, targetName, slot, slots, attemptId }: BrowserRequest): string { + const suffix = attemptId ?? String(slot + 1); + if (sessionName === undefined) return `e2e-${runId}-${targetName}-${suffix}`; + return attemptId !== undefined || slots > 1 ? `${sessionName}-${suffix}` : sessionName; +} + +/** 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/testmu/src/index.ts b/packages/testmu/src/index.ts index 65b060dfa..cdce6162a 100644 --- a/packages/testmu/src/index.ts +++ b/packages/testmu/src/index.ts @@ -2,6 +2,8 @@ * `@e2e-dev/testmu` public surface: `testmu()`, a device provider that leases * TestMu AI (formerly LambdaTest) Android emulators, iOS simulators, and real * devices for `@e2e-dev/mobile` through agent-device's `testmu` provider. + * `testmuBrowsers()`, for TestMu AI's hosted Chrome and Edge on `@e2e-dev/web`, + * is exported from `@e2e-dev/testmu/web`. */ export { testmu } from './provider.ts'; diff --git a/packages/testmu/src/web.ts b/packages/testmu/src/web.ts new file mode 100644 index 000000000..07ae2ccca --- /dev/null +++ b/packages/testmu/src/web.ts @@ -0,0 +1,8 @@ +/** + * `@e2e-dev/testmu/web`: `testmuBrowsers()`, for web targets. It loads neither + * `@e2e-dev/mobile` nor agent-device, and the package root, which exports the + * device provider, does not reference `@e2e-dev/web`. + */ + +export { testmuBrowsers } from './browsers.ts'; +export type { TestmuBrowserName, TestmuBrowsersOptions, TestmuBrowsersRoute } from './browsers.ts'; diff --git a/packages/testmu/tests/unit/browsers.test.ts b/packages/testmu/tests/unit/browsers.test.ts new file mode 100644 index 000000000..b6a3e52ef --- /dev/null +++ b/packages/testmu/tests/unit/browsers.test.ts @@ -0,0 +1,212 @@ +/** + * `testmuBrowsers()` 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 { testmuBrowsers, type TestmuBrowsersOptions } from '../../src/web.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('testmuBrowsers() must not call the network'); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('testmuBrowsers()', () => { + it('builds a /puppeteer CDP URL on cdp.lambdatest.com with the run and target in the names', async () => { + const lease = await testmuBrowsers().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', + project: 'e2e', + build: 'run-1', + name: 'e2e-run-1-chromium-1', + 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 = testmuBrowsers({ 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-run-1-chromium-attempt-7'); + expect(lease.id).toBe('chromium:attempt-7'); + }); + + it('leaves scope to the engine default when none is given', () => { + expect('scope' in testmuBrowsers()).toBe(false); + }); + + it('honours the route, hub, browser, platform, build, and extra capabilities', async () => { + const lease = await testmuBrowsers({ + route: '/playwright-cdp', + hub: 'cdp.eu.example.test', + browserName: 'MicrosoftEdge', + browserVersion: '140', + platform: 'macOS Sequoia', + build: 'nightly', + project: 'checkout', + geoLocation: 'FR', + timezone: 'UTC+01:00', + 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', + project: 'checkout', + build: 'nightly', + geoLocation: 'FR', + timezone: 'UTC+01:00', + video: true, + idleTimeout: 300, + }); + }); + + it('names sessions like testmu() does: a given sessionName gets the slot or attempt appended when needed', async () => { + const nameOf = async (options: TestmuBrowsersOptions, overrides: Partial = {}) => + decode((await testmuBrowsers(options).acquire(request(overrides))).cdpEndpoint).capabilities['LT:Options']['name']; + + expect(await nameOf({ sessionName: 'smoke' }, { slots: 1 })).toBe('smoke'); + expect(await nameOf({ sessionName: 'smoke' }, { slot: 1, slots: 2 })).toBe('smoke-2'); + expect(await nameOf({ sessionName: 'smoke', scope: 'attempt' }, { attemptId: 'attempt-3' })).toBe('smoke-attempt-3'); + expect(await nameOf({}, { slot: 1, slots: 2 })).toBe('e2e-run-1-chromium-2'); + }); + + 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 testmuBrowsers().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(testmuBrowsers().acquire(request({ env: { LT_USERNAME: 'alice' } }))).rejects.toThrow('LT_ACCESS_KEY is not set'); + await expect(testmuBrowsers().acquire(request({ env: { LT_USERNAME: ' ', LT_ACCESS_KEY: ACCESS_KEY } }))).rejects.toThrow('LT_USERNAME is not set'); + }); + + it.each([ + [{ platfrom: 'macOS Sequoia' }, 'testmuBrowsers() 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`'], + [{ capabilities: { browserName: 'Firefox', browserVersion: '120' } }, '`capabilities` cannot set `browserName`, `browserVersion`'], + [{ capabilities: { 'LT:Options': { video: true } } }, '`capabilities` cannot set `LT:Options`'], + [{ hub: null }, '`hub` must be a host'], + [{ route: null }, '`route` must be one of'], + [{ browserName: null }, '`browserName` must be one of'], + [{ scope: 'session' }, '`scope` must be one of worker, attempt'], + [{ scope: null }, '`scope` must be one of worker, attempt'], + [{ build: '' }, '`build` must be a non-empty string'], + [{ project: 1 }, '`project` must be a non-empty string'], + [{ sessionName: null }, '`sessionName` must be a non-empty string'], + [{ browserVersion: ' ' }, '`browserVersion` must be a non-empty string'], + [{ capabilities: { project: 'x', geoLocation: 'US' } }, '`capabilities` cannot set `project`, `geoLocation`'], + [null, 'testmuBrowsers() options must be an object'], + [[], 'testmuBrowsers() 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(() => testmuBrowsers(options as unknown as TestmuBrowsersOptions)).toThrow( + expect.objectContaining({ code: 'INVALID_CONFIG', message: expect.stringContaining(message) }), + ); + }); + + it('keeps 24 worker-scope leases inside the engine\'s 16 KB hand-off', async () => { + const provider = testmuBrowsers({ 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: 24 }, (_, slot) => provider.acquire(request({ runId, targetName: 'checkout-web', slot, slots: 24, env }))), + ); + + expect(Buffer.byteLength(JSON.stringify({ slots: 24, leases }))).toBeLessThan(16 * 1024); + }); + + it('logs the session and build names but never the access key or the URL', async () => { + const req = request(); + await testmuBrowsers().acquire(req); + + expect(req.lines).toEqual(['TestMu AI session "e2e-run-1-chromium-1" in build "run-1"']); + expect(req.lines.join('\n')).not.toContain(ACCESS_KEY); + expect(req.lines.join('\n')).not.toContain('wss://'); + }); + + it('is exported from @e2e-dev/testmu/web only: the package root stays device-only', async () => { + const root: Record = await import('../../src/index.ts'); + expect(Object.keys(root)).toEqual(['testmu']); + }); + + it('releases without calling anything', async () => { + const provider = testmuBrowsers(); + 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/pnpm-lock.yaml b/pnpm-lock.yaml index b9f2d6fc1..546489bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,6 +533,9 @@ importers: '@e2e-dev/mobile': specifier: workspace:* version: link:../mobile + '@e2e-dev/web': + specifier: workspace:* + version: link:../web '@types/node': specifier: 26.6.2 version: 26.6.2 diff --git a/scripts/check-docs-examples.ts b/scripts/check-docs-examples.ts index aed7a0aad..41a89cc9e 100644 --- a/scripts/check-docs-examples.ts +++ b/scripts/check-docs-examples.ts @@ -30,6 +30,7 @@ const EXAMPLES: Record = { 'docs/examples/mobile/testmu.config.ts': 'docs/integrations/testmu.mdx', 'docs/examples/mobile/testmu.e2e.ts': 'docs/integrations/testmu.mdx', 'docs/examples/web/browser-provider.ts': 'docs/browser.mdx', + 'docs/examples/web/testmu-browsers.config.ts': 'docs/integrations/testmu-browsers.mdx', 'docs/examples/models/e2e.decision.config.ts': 'docs/decision-models.mdx', 'docs/examples/models/tests/decision.e2e.ts': 'docs/decision-models.mdx', 'docs/examples/skill/e2e.config.ts': 'skills/e2e/SKILL.md', diff --git a/skills/e2e/references/setup.md b/skills/e2e/references/setup.md index 88a4bbbc9..aa161b7f5 100644 --- a/skills/e2e/references/setup.md +++ b/skills/e2e/references/setup.md @@ -180,7 +180,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`, `testmuBrowsers()` from `@e2e-dev/testmu/web`, 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. |