Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
# @suaveplan/testing-e2e - Stealth Playwright E2E utilities backed by cloakbrowser — config factory, page objects, fixtures, helpers, structured errors. Drop-in browser-launch swap that passes Cloudflare Turnstile, reCAPTCHA v3, FingerprintJS. Layer 0; no @suaveplan/* runtime deps
## Summary
Playwright E2E testing utilities for Suaveplan, with the
default `browser` worker fixture launching
[`cloakbrowser`](https://github.com/CloakHQ/cloakbrowser) — a real
Chromium binary with 49 source-level C++ fingerprint patches — instead
of the stock Playwright Chromium. Every spec that imports
`extendedTest` from this package gets `navigator.webdriver === false`,
real GPU/canvas/audio/screen/WebRTC fingerprints, a normal
`window.chrome`, no `HeadlessChrome` UA leak, and no CDP detection
signal. Cloudflare Turnstile, reCAPTCHA v3 (0.9 score), FingerprintJS,
BrowserScan, and ShieldSquare see a normal browser.
The rest of the package is unchanged: a config factory with WSL2/CI
defaults, an abstract `PageObject` base, an `apiContext` fixture that
deliberately does NOT depend on `browser`, network/console monitors,
multi-session orchestration, OTel-instrumented entry points, and a
structured `TestingE2eError` hierarchy (now with `CLOAK_*` codes).
Layer 0 — no `@suaveplan/*` runtime dependencies.
### What the swap changes
- `extendedTest.browser` is launched via `cloakbrowser.launch`. The
returned object is still a Playwright `Browser` from
`playwright-core` — every fixture, page object, helper, and monitor
works unchanged.
- A new worker option `cloak: CloakOptions` flows into the launcher.
Set per project in `playwright.config.ts`:
`use: { cloak: { humanize: true, geoip: true, proxy: "…" } }`.
- `launchWithExtension` defaults to
`cloakbrowser.launchPersistentContext` and exposes a
`launchPersistentContext` injector for tests / profile managers.
- Failures classify into `TESTING_E2E.CLOAK.{LAUNCH_FAILED,BINARY_MISSING,PROXY_INVALID}`
via `classifyCloakError` + `wrapCloakError`, wrapped in
`CloakLaunchError`.
- `@playwright/test` stays as the test runner — cloakbrowser is a
drop-in browser-launch replacement, not a runner replacement.
- **No public re-exports of `cloakbrowser` symbols.** Per CLAUDE.md
rule 4, when bypassing the fixture layer, consumers
`import { launch } from "cloakbrowser"` directly.
### Binary footprint
On first launch the stealth Chromium binary (~200 MB) is auto-
downloaded to `~/.cache/cloakbrowser/`. CI runners pre-cache it via
`npx cloakbrowser install` in the setup step (parity with
`playwright install chromium`).
<!-- begin:auto-docs -->
## Documentation tree
- [build-cloakserve-url](./src/cloakserve/build-cloakserve-url.md) — `buildCloakServeUrl` constructs the WebSocket / HTTP endpoint URL for `cloakserve`, the cloakbrowser CDP multiplexer th…
- [cloakserve-connect](./src/cloakserve/cloakserve-connect.md) — `connectCloakServe` connects Playwright to a running `cloakserve` CDP multiplexer over `chromium.connectOverCDP(...)`,…
- [config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — `launchWithExtension` starts a stealth Chromium persistent browser context with an unpacked browser extension loaded, t…
- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — `launchWithExtension` starts a stealth Chromium persistent browser context with an unpacked browser extension loaded, t…
- [fixtures](./src/fixtures/extended-test.md) — `extendedTest` is the Playwright `test` function pre-extended with the fixtures this package ships, and — critically —…
- [extended-test](./src/fixtures/extended-test.md) — `extendedTest` is the Playwright `test` function pre-extended with the fixtures this package ships, and — critically —…
- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [presets](./src/presets/stealth-presets.md) — Ready-made `CloakOptions` bundles for the most common cloakbrowser deployment shapes. Pulled from the official CloakBro…
- [stealth-presets](./src/presets/stealth-presets.md) — Ready-made `CloakOptions` bundles for the most common cloakbrowser deployment shapes. Pulled from the official CloakBro…
<!-- end:auto-docs --> - [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_ - [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_ - [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ - [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/testing-e2e
```
Requires `@playwright/test` and `cloakbrowser` (installed automatically as direct dependencies). On first launch cloakbrowser auto-downloads the stealth Chromium binary; pre-cache in CI with `npx cloakbrowser install`.
## Quick Start
### Playwright config
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: "http://localhost:3000",
testDir: "e2e",
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
],
});
```
### Page object + stealth extended fixtures
```ts
// playwright.config.ts — cloak options for the default browser fixture
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: "http://localhost:3000",
testDir: "e2e",
use: {
cloak: {
headless: true,
humanize: true,
geoip: true,
proxy: "socks5://user:pass@residential:1080",
},
},
});
```
```ts
// e2e/pages/login.page.ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
export class LoginPage extends PageObject {
public readonly path = "/login";
get emailInput() { return this.findByLabel("Email"); }
get submitButton() { return this.findByRole("button", { name: /sign in/i }); }
}
// e2e/login.spec.ts — runs against a Cloudflare-walled app
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
import { waitForNetworkIdle } from "@suaveplan/testing-e2e/helpers";
import { LoginPage } from "./pages/login.page.js";
test("signs in through Cloudflare Turnstile", async ({ page }) => {
const login = new LoginPage(page);
await login.navigate(); // Turnstile lets us through
await login.emailInput.fill("user@example.com");
await login.submitButton.click();
await waitForNetworkIdle(page);
await expect(page).toHaveURL("/dashboard");
});
```
### Programmatic launch (outside the Playwright runner)
```ts
import { launchBrowserWithCloak } from "@suaveplan/testing-e2e/fixtures";
const browser = await launchBrowserWithCloak({
humanize: true,
geoip: true,
proxy: "http://user:pass@proxy:8080",
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://bot.incolumitas.com");
console.log(await page.title());
await browser.close();
```
### Extension fixture (stealth + persistent profile)
```ts
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
const { context, extensionId } = await launchWithExtension({
extensionPath: "/path/to/unpacked-extension",
userDataDir: "/tmp/profile",
launchOptions: { humanize: true, geoip: true },
});
```
### Cloakserve CDP server (shared container, N parallel workers)
```bash
# One container, all your test workers share it
cd packages/testing/testing-e2e/docker
docker compose up -d cloakserve
```
```ts
import { connectCloakServe, stealthMax } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111, // per-worker seed
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.RESIDENTIAL_PROXY,
geoip: true,
});
```
### Stealth presets
```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { stealthMax } from "@suaveplan/testing-e2e";
export default defineConfig({
use: {
// Upstream-recommended config: residential proxy + geoip + headed + humanize.
cloak: stealthMax(process.env.RESIDENTIAL_PROXY!),
},
});
```
`STEALTH_DEFAULT_CLOAK` / `STEALTH_HEADED_CLOAK` are also exported
for the lighter shapes. See `src/presets/stealth-presets.md` for the
full menu.
### Docker bundle
`packages/testing/testing-e2e/docker/` ships:
- `Dockerfile.cloakserve` — extends `cloakhq/cloakbrowser`, adds the
Kasada / Akamai font set, runs `cloakserve` on 9222
- `Dockerfile.manager` — extends `cloakhq/cloakbrowser-manager`,
exposes 8080 with a `cloakprofiles` named volume for persistence
- `docker-compose.yml` — both services on `127.0.0.1`, with the
manager gated behind the `manager` compose profile
- `.env.example` — pinnable `CLOAKBROWSER_TAG`, `MANAGER_TAG`, and
`MANAGER_AUTH_TOKEN`
See `docker/README.md` for the full operator playbook.
## Modules
| Subpath | Module | Description |
|---|---|---|
| `@suaveplan/testing-e2e/config` | [src/config/create-playwright-config.md](src/config/create-playwright-config.md) | `createPlaywrightConfig` — emits a `PlaywrightTestConfig` with WSL2-safe defaults (`fullyParallel: false`, trace/screenshot/video retention) and automatic CI mode (`retries: 2`, `workers: 1`) |
| `@suaveplan/testing-e2e/page-objects` | [src/page-objects/page-object.md](src/page-objects/page-object.md) | Abstract `PageObject` base class — typed wrapper around a Playwright `Page` with `navigate()`, `waitForReady()`, and accessibility-first locator helpers (`findByRole`, `findByLabel`, `findByText`) |
| `@suaveplan/testing-e2e/fixtures` | [src/fixtures/extended-test.md](src/fixtures/extended-test.md) | `extendedTest` — `test.extend(...)` with the `browser` worker fixture overridden to launch `cloakbrowser`; plus `apiContext` (isolated `APIRequestContext`, browser-free) and `freshPage` (isolated browser context + page); also exports `launchBrowserWithCloak` and `expect` |
| `@suaveplan/testing-e2e/helpers` | [src/helpers/wait-for-network-idle.md](src/helpers/wait-for-network-idle.md) | `waitForNetworkIdle` — polls in-flight request count and resolves once the network has been idle for `idleMs` (default 500 ms); throws `WaitTimeoutError` on timeout |
| | [src/helpers/mock-clipboard.md](src/helpers/mock-clipboard.md) | `mockClipboard` — installs a programmatic in-page clipboard so tests can assert `writeText`/`readText` without OS permissions |
| | [src/helpers/mock-geolocation.md](src/helpers/mock-geolocation.md) | `mockGeolocation` — grants geolocation permission and sets static latitude/longitude/accuracy on a `BrowserContext` |
| | [src/helpers/screenshot-on-failure.md](src/helpers/screenshot-on-failure.md) | `screenshotOnFailure` — captures a full-page screenshot named after the failing test; useful as a manual teardown hook alongside Playwright's built-in artifact retention |
| | [src/helpers/dismiss-dialog.md](src/helpers/dismiss-dialog.md) | `dismissDialog` — registers a one-shot `dialog` handler that accepts or dismisses the next browser dialog (`alert`, `confirm`, `prompt`) |
| `@suaveplan/testing-e2e/errors` | [src/errors/testing-e2e-error.md](src/errors/testing-e2e-error.md) | Typed error classes (`NavigationError`, `WaitTimeoutError`, `AbortedError`, `CloakLaunchError`) + `classifyCloakError` / `wrapCloakError` helpers |
| | [src/errors/codes.md](src/errors/codes.md) | `TESTING_E2E_ERROR_CODES` registry |
| _(observability)_ | [src/observability/instrumented.md](src/observability/instrumented.md) | `instrumented` — optional OTel wrapper (gated by `TESTING_E2E_OTEL_ENABLED=1`) that records call counts and duration histograms for package entrypoints |

View File

@@ -0,0 +1,13 @@
# Pin a specific cloakbrowser image tag (default: latest).
# Match this to the `cloakbrowser` npm package version used in
# packages/testing/testing-e2e to guarantee the binary matches the
# wrapper API surface.
CLOAKBROWSER_TAG=0.3.28
# Pin the cloakbrowser-manager image tag (default: latest).
MANAGER_TAG=latest
# Set to a long random string before exposing the manager past
# localhost. Required for the API Authorization header and the web
# UI login screen.
MANAGER_AUTH_TOKEN=

View File

@@ -0,0 +1,29 @@
# Cloakserve image — wraps the upstream cloakhq/cloakbrowser image and
# starts the `cloakserve` CDP multiplexer on container start. Pair
# with @suaveplan/testing-e2e's `connectCloakServe` helper.
#
# Build: docker build -f Dockerfile.cloakserve -t suaveplan-cloakserve .
# Default upstream tag: latest. Pin via --build-arg CLOAKBROWSER_TAG=0.3.28.
ARG CLOAKBROWSER_TAG=latest
FROM cloakhq/cloakbrowser:${CLOAKBROWSER_TAG}
# Bundle the canvas-emoji + extended font set the upstream README
# flags as required for Kasada / Akamai (`Font Setup on Linux`).
# The base image already has noto-color-emoji; we add the rest for
# completeness on minimal derivatives.
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fonts-freefont-ttf \
fonts-unifont \
fonts-ipafont-gothic \
fonts-wqy-zenhei \
fonts-tlwg-loma-otf \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -f
EXPOSE 9222
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:9222/json/version || exit 1
CMD ["cloakserve"]

View File

@@ -0,0 +1,18 @@
# CloakBrowser Manager image — thin wrapper around the upstream
# cloakhq/cloakbrowser-manager image. Provides a noVNC + REST profile
# manager (Multilogin / GoLogin / AdsPower alternative) on port 8080.
#
# AUTH_TOKEN: set to enable bearer-token auth on the API and a login
# screen on the web UI. Leave unset for trusted local use only.
#
# Build: docker build -f Dockerfile.manager -t suaveplan-cloakbrowser-manager .
ARG MANAGER_TAG=latest
FROM cloakhq/cloakbrowser-manager:${MANAGER_TAG}
# Profiles persist under /data; expose so docker-compose can mount a
# named volume.
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8080/api/status || exit 1

View File

@@ -0,0 +1,76 @@
# Docker bundle for `@suaveplan/testing-e2e`
Two services that round-trip with `@suaveplan/testing-e2e`'s
stealth-Chromium fixtures.
## Services
| Service | Image | Port | Purpose |
| -------------- | ------------------------------------ | -------------- | --------------------------------------------------------------------------------------------- |
| `cloakserve` | `suaveplan-cloakserve:local` | `127.0.0.1:9222` | CDP multiplexer — one container, N parallel test workers, each picking a fingerprint seed. Pair with `connectCloakServe()`. |
| `manager` | `suaveplan-cloakbrowser-manager:local` | `127.0.0.1:8080` | Optional noVNC + REST profile manager (Multilogin / GoLogin / AdsPower alternative). Enable via the `manager` compose profile. |
Both services bind to `127.0.0.1` only. **Never expose 9222 or 8080
to the public internet** without a reverse proxy + auth — both grant
full browser control.
## Quick start
```bash
# CDP server only — recommended for most CI / dev loops
docker compose up -d cloakserve
# + GUI profile manager (enables the `manager` compose profile)
docker compose --profile manager up -d
# Tear down
docker compose down
```
Pin specific upstream tags via env vars:
```bash
CLOAKBROWSER_TAG=0.3.28 MANAGER_TAG=latest docker compose up -d cloakserve
```
Set an auth token on the manager before exposing past localhost:
```bash
MANAGER_AUTH_TOKEN="$(openssl rand -hex 32)" docker compose --profile manager up -d
```
## Connecting from tests
```ts
import { connectCloakServe } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111,
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.RESIDENTIAL_PROXY,
geoip: true,
});
```
Each unique `fingerprint` value spawns a separate Chromium process
inside the container. Reuse the same seed across runs to look like a
returning visitor (recommended for reCAPTCHA v3 / Enterprise scoring).
Use `testInfo.parallelIndex` as the seed source to give each worker an
isolated identity.
## Fonts
The `cloakserve` image bundles the font packages the upstream
CloakBrowser README flags as required for Kasada / Akamai:
- `fonts-noto-color-emoji` *(inherited from base)*
- `fonts-freefont-ttf`
- `fonts-unifont`
- `fonts-ipafont-gothic`
- `fonts-wqy-zenhei`
- `fonts-tlwg-loma-otf`
Without these, canvas emoji rendering hashes don't match any real
browser and aggressive bot detection flags the session.

View File

@@ -0,0 +1,63 @@
# Stealth browser stack for @suaveplan/testing-e2e.
#
# - `cloakserve`: stealth Chromium CDP multiplexer on 127.0.0.1:9222.
# Use with `connectCloakServe()` from @suaveplan/testing-e2e — one
# container, many parallel test workers, each seeding its own
# isolated Chromium identity via fingerprint query params.
# - `cloakbrowser-manager`: optional GUI profile manager on
# 127.0.0.1:8080. Wholly separate from `cloakserve`; spin up only
# when interactively curating profiles. Set AUTH_TOKEN before
# exposing beyond localhost.
#
# Usage:
# docker compose up -d cloakserve # CDP server only
# docker compose up -d cloakserve manager # + profile manager
# docker compose down # tear down
#
# Both services bind to 127.0.0.1. NEVER expose CDP (9222) or the
# manager (8080) directly to the public internet — both grant
# full browser control. Always front with a reverse proxy + auth.
services:
cloakserve:
build:
context: .
dockerfile: Dockerfile.cloakserve
args:
CLOAKBROWSER_TAG: "${CLOAKBROWSER_TAG:-latest}"
image: suaveplan-cloakserve:local
container_name: suaveplan-cloakserve
restart: unless-stopped
ports:
- "127.0.0.1:9222:9222"
environment:
CLOAKBROWSER_AUTO_UPDATE: "false"
healthcheck:
test:
["CMD", "curl", "-fsS", "http://localhost:9222/json/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
manager:
build:
context: .
dockerfile: Dockerfile.manager
args:
MANAGER_TAG: "${MANAGER_TAG:-latest}"
image: suaveplan-cloakbrowser-manager:local
container_name: suaveplan-cloakbrowser-manager
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- cloakprofiles:/data
environment:
AUTH_TOKEN: "${MANAGER_AUTH_TOKEN:-}"
profiles:
- manager
volumes:
cloakprofiles:
name: suaveplan-cloakprofiles

View File

@@ -0,0 +1,108 @@
{
"name": "@suaveplan/testing-e2e",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stealth Playwright E2E utilities backed by cloakbrowser — config factory, page objects, fixtures, helpers, structured errors. Drop-in browser-launch swap that passes Cloudflare Turnstile, reCAPTCHA v3, FingerprintJS. Layer 0; no @suaveplan/* runtime deps",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./config": {
"types": "./dist/config/create-playwright-config.d.ts",
"default": "./dist/config/create-playwright-config.js"
},
"./errors": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"./helpers": {
"types": "./dist/helpers/index.d.ts",
"default": "./dist/helpers/index.js"
},
"./page-objects": {
"types": "./dist/page-objects/page-object.d.ts",
"default": "./dist/page-objects/page-object.js"
},
"./fixtures": {
"types": "./dist/fixtures/extended-test.d.ts",
"default": "./dist/fixtures/extended-test.js"
},
"./multi-session": {
"types": "./dist/multi-session/index.d.ts",
"default": "./dist/multi-session/index.js"
},
"./console-monitor": {
"types": "./dist/console-monitor/index.d.ts",
"default": "./dist/console-monitor/index.js"
},
"./network-monitor": {
"types": "./dist/network-monitor/index.d.ts",
"default": "./dist/network-monitor/index.js"
},
"./extension-fixture": {
"types": "./dist/extension-fixture/index.d.ts",
"default": "./dist/extension-fixture/index.js"
},
"./cloakserve": {
"types": "./dist/cloakserve/index.d.ts",
"default": "./dist/cloakserve/index.js"
},
"./presets": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@playwright/test": "catalog:testing",
"cloakbrowser": "catalog:testing",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,92 @@
# build-cloakserve-url
## Purpose
`buildCloakServeUrl` constructs the WebSocket / HTTP endpoint URL for
[`cloakserve`](https://github.com/CloakHQ/CloakBrowser#cdp-server-mode),
the cloakbrowser CDP multiplexer that lets a single Docker container
expose multiple stealth Chromium identities keyed by fingerprint seed.
The cloakserve CDP endpoint accepts a documented set of query
parameters (`fingerprint`, `timezone`, `locale`, `platform`,
`gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`,
`screen-width`, `screen-height`, `proxy`, `geoip`, …) that each
control one facet of the spawned browser's identity. Same seed →
same Chromium process and fingerprint; different seed → fresh
isolated process. This helper builds the URL deterministically from a
strongly-typed options bundle.
## Features
- Pure synchronous URL builder — no I/O
- camelCase → kebab-case key mapping
(`hardwareConcurrency` → `hardware-concurrency`)
- Default base URL `http://localhost:9222` (cloakserve's default
bind); override via `url`
- Preserves existing query-string params on the base URL
- Stringifies booleans (`geoip: true` → `geoip=true`) and numbers
- Skips `undefined` / `null` values
## Usage: Basic Example
```typescript
import { buildCloakServeUrl } from "@suaveplan/testing-e2e";
const url = buildCloakServeUrl({ fingerprint: 11111 });
// → "http://localhost:9222/?fingerprint=11111"
```
## Usage: Advanced Example
```typescript
import { buildCloakServeUrl } from "@suaveplan/testing-e2e";
const url = buildCloakServeUrl({
url: "http://cloakserve.internal:9222",
fingerprint: 42069,
timezone: "Asia/Tokyo",
locale: "ja-JP",
platform: "macos",
gpuVendor: "Apple Inc.",
gpuRenderer: "Apple M2",
hardwareConcurrency: 8,
deviceMemory: 16,
screenWidth: 1440,
screenHeight: 900,
proxy: "socks5://user:pass@residential:1080",
geoip: true,
});
// → "http://cloakserve.internal:9222/?fingerprint=42069&timezone=Asia%2FTokyo
// &locale=ja-JP&platform=macos&gpu-vendor=Apple+Inc.&gpu-renderer=Apple+M2
// &hardware-concurrency=8&device-memory=16&screen-width=1440
// &screen-height=900&proxy=…&geoip=true"
```
## API Reference
### `buildCloakServeUrl(options?: CloakServeConnectOptions): string`
- `options.url` — base cloakserve endpoint URL. Default
`http://localhost:9222`
- All other fields in `CloakServeFingerprint` are appended as
query-string parameters using cloakserve's kebab-case key names
### `CloakServeConnectOptions`
See `cloakserve-options.types.ts` for the full type. Notable fields:
- `fingerprint: string | number` — master seed (returning-visitor
identity if held constant)
- `timezone`, `locale` — IANA / BCP-47 strings; routed through
cloakbrowser's undetectable binary flags (not CDP emulation)
- `proxy`, `geoip` — proxy URL + optional geo-IP enrichment
## Implementation Notes
Uses the WHATWG `URL` API so existing query params on the base URL
survive (`?customParam=x` is preserved when adding fingerprint params).
camelCase → kebab-case mapping is a static frozen table; new
parameters require both a `CloakServeFingerprint` field and a
`KEY_MAP` entry. The function is intentionally side-effect free so it
can be called inside hot paths (per-test URL generation in a fixture
runner with thousands of workers).

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { buildCloakServeUrl } from "./build-cloakserve-url.js";
describe("buildCloakServeUrl", () => {
it("defaults to localhost:9222 with no params", { timeout: 5000 }, () => {
const url = buildCloakServeUrl();
expect(url).toBe("http://localhost:9222/");
});
it("appends fingerprint seed", { timeout: 5000 }, () => {
expect(buildCloakServeUrl({ fingerprint: 11111 })).toBe(
"http://localhost:9222/?fingerprint=11111"
);
expect(buildCloakServeUrl({ fingerprint: "deadbeef" })).toBe(
"http://localhost:9222/?fingerprint=deadbeef"
);
});
it("maps camelCase to kebab-case", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
hardwareConcurrency: 8,
deviceMemory: 16,
screenWidth: 1920,
screenHeight: 1080,
gpuVendor: "Intel",
gpuRenderer: "Iris",
platformVersion: "15.0",
brandVersion: "146.0.0.0",
});
expect(url).toContain("hardware-concurrency=8");
expect(url).toContain("device-memory=16");
expect(url).toContain("screen-width=1920");
expect(url).toContain("screen-height=1080");
expect(url).toContain("gpu-vendor=Intel");
expect(url).toContain("gpu-renderer=Iris");
expect(url).toContain("platform-version=15.0");
expect(url).toContain("brand-version=146.0.0.0");
});
it("stringifies booleans", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({ geoip: true });
expect(url).toContain("geoip=true");
const url2 = buildCloakServeUrl({ geoip: false });
expect(url2).toContain("geoip=false");
});
it("honours custom base url", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
url: "http://cloakserve.internal:9222",
fingerprint: 42,
});
expect(url).toBe("http://cloakserve.internal:9222/?fingerprint=42");
});
it("preserves existing query params on base url", {
timeout: 5000,
}, () => {
const url = buildCloakServeUrl({
url: "http://localhost:9222/?existing=keep",
fingerprint: 7,
});
expect(url).toContain("existing=keep");
expect(url).toContain("fingerprint=7");
});
it("skips undefined and null", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
fingerprint: 1,
timezone: undefined,
locale: undefined as unknown as string,
});
expect(url).not.toContain("timezone");
expect(url).not.toContain("locale");
});
it("sets timezone, locale, platform together", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
timezone: "Asia/Tokyo",
locale: "ja-JP",
platform: "macos",
});
expect(url).toContain("timezone=Asia%2FTokyo");
expect(url).toContain("locale=ja-JP");
expect(url).toContain("platform=macos");
});
it("includes proxy when set", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
proxy: "socks5://u:p@h:1080",
});
expect(url).toContain("proxy=socks5");
});
it("brand and brandVersion both map", { timeout: 5000 }, () => {
const url = buildCloakServeUrl({
brand: "Edge",
brandVersion: "146.0.1",
});
expect(url).toContain("brand=Edge");
expect(url).toContain("brand-version=146.0.1");
});
});

View File

@@ -0,0 +1,49 @@
import type { CloakServeConnectOptions } from "./cloakserve-options.types.js";
const KEY_MAP: Readonly<
Record<keyof Omit<CloakServeConnectOptions, "url">, string>
> = {
fingerprint: "fingerprint",
timezone: "timezone",
locale: "locale",
platform: "platform",
platformVersion: "platform-version",
brand: "brand",
brandVersion: "brand-version",
gpuVendor: "gpu-vendor",
gpuRenderer: "gpu-renderer",
hardwareConcurrency: "hardware-concurrency",
deviceMemory: "device-memory",
screenWidth: "screen-width",
screenHeight: "screen-height",
proxy: "proxy",
geoip: "geoip",
};
const DEFAULT_BASE = "http://localhost:9222";
/**
* Build a cloakserve CDP URL with fingerprint/proxy/geoip query params.
*
* Pure function. The base URL defaults to `http://localhost:9222`;
* pass `url` to point at a remote cloakserve. Existing query string
* params on the base URL are preserved and merged.
*
* camelCase wrapper keys are mapped to the kebab-case query-string
* keys cloakserve expects (`hardwareConcurrency` → `hardware-concurrency`).
* Values are stringified via `String(...)`; booleans become
* `"true"`/`"false"`.
*/
export function buildCloakServeUrl(
options: CloakServeConnectOptions = {}
): string {
const { url = DEFAULT_BASE, ...rest } = options;
const u = new URL(url);
for (const [key, value] of Object.entries(rest) as Array<
[keyof typeof KEY_MAP, unknown]
>) {
if (value === undefined || value === null) continue;
u.searchParams.set(KEY_MAP[key], String(value));
}
return u.toString();
}

View File

@@ -0,0 +1,102 @@
# cloakserve-connect
## Purpose
`connectCloakServe` connects Playwright to a running
[`cloakserve`](https://github.com/CloakHQ/CloakBrowser#cdp-server-mode)
CDP multiplexer over `chromium.connectOverCDP(...)`, with the
fingerprint / proxy / geoip / locale parameters expressed as a
strongly-typed options bundle rather than a hand-rolled query string.
The helper exists so test suites can share a single Docker container
of `cloakhq/cloakbrowser` running `cloakserve` across many parallel
workers — each worker requests its own seeded fingerprint via a
URL parameter, cloakserve spawns (or reuses) a stealth Chromium
process for that seed, and you save the ~200 MB binary download +
launch latency that an in-process `cloakbrowser.launch()` per worker
would incur.
## Features
- Strongly-typed `CloakServeConnectOptions` (mirrors the documented
cloakserve query-param surface)
- Uses {@link buildCloakServeUrl} for URL composition
- Pluggable Playwright `chromium` driver (defaults to
`@playwright/test`'s built-in `chromium`)
- Errors classified by `classifyCloakError` and wrapped in a
`CloakLaunchError` with `cloakServeUrl` in `context`
- Cache-busting test hook (`_resetCloakServeChromiumCache`)
## Usage: Basic Example
```typescript
import { connectCloakServe } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111,
});
const page = await browser.newPage();
await page.goto("https://protected-site.com");
```
## Usage: Advanced Example
```typescript
// Parallel workers share one cloakserve container.
import { connectCloakServe } from "@suaveplan/testing-e2e";
import { test } from "@suaveplan/testing-e2e";
test("worker-isolated identity", async ({}, testInfo) => {
// Per-worker seed gives each worker an isolated Chromium process.
const browser = await connectCloakServe({
url: process.env.CLOAKSERVE_URL ?? "http://localhost:9222",
fingerprint: 10000 + testInfo.parallelIndex,
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.PROXY,
geoip: true,
});
try {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://example.com");
await ctx.close();
} finally {
await browser.close();
}
});
```
## API Reference
### `connectCloakServe(options?, chromium?): Promise<Browser>`
Connects to a cloakserve CDP endpoint and returns a Playwright
`Browser`. `chromium` defaults to `@playwright/test`'s built-in.
**Throws:** `CloakLaunchError` with
`TESTING_E2E.CLOAK.{LAUNCH_FAILED|BINARY_MISSING|PROXY_INVALID}`
when `connectOverCDP` rejects. `context.cloakServeUrl` carries the
URL that was attempted.
### `_resetCloakServeChromiumCache(): void`
Internal hook to reset the lazy dynamic-import cache. Exposed for
unit tests; production code should never call this.
## Implementation Notes
The `connectOverCDP` call goes through `@playwright/test`'s
`chromium` rather than `playwright-core` directly because tests in
this project already pins `@playwright/test` as the test runner and
Playwright re-exports `chromium` from there. The injected `chromium`
parameter only needs `connectOverCDP` — typed as
`Pick<BrowserType, "connectOverCDP">` so spies are cheap to write.
When pointing at a remote cloakserve, **never** expose port 9222 to
the public internet without an upstream auth layer. CDP gives full
control over the browser; see the security warning in the upstream
CloakBrowser README. The bundled `docker-compose.yml` in
`packages/testing/testing-e2e/docker/` binds to `127.0.0.1` by
default.

View File

@@ -0,0 +1,130 @@
import type { Browser, BrowserType } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { CloakLaunchError, TESTING_E2E_ERROR_CODES } from "../errors/index.js";
import {
_resetCloakServeChromiumCache,
connectCloakServe,
} from "./cloakserve-connect.js";
function makeFakeBrowser(): Browser {
return { close: vi.fn(async () => undefined) } as unknown as Browser;
}
describe("connectCloakServe", () => {
it("uses injected chromium and builds the cloakserve URL", {
timeout: 5000,
}, async () => {
const browser = makeFakeBrowser();
const driver = {
connectOverCDP: vi.fn(async () => browser),
} as unknown as Pick<BrowserType, "connectOverCDP">;
const spy = driver.connectOverCDP as ReturnType<typeof vi.fn>;
const out = await connectCloakServe(
{ fingerprint: 11111, timezone: "America/New_York" },
driver
);
expect(out).toBe(browser);
expect(spy).toHaveBeenCalledTimes(1);
const calledWith = spy.mock.calls[0]?.[0] as string;
expect(calledWith).toContain("fingerprint=11111");
expect(calledWith).toContain("timezone=America%2FNew_York");
});
it("defaults to http://localhost:9222 when no url given", {
timeout: 5000,
}, async () => {
const driver = {
connectOverCDP: vi.fn(async () => makeFakeBrowser()),
} as unknown as Pick<BrowserType, "connectOverCDP">;
await connectCloakServe({}, driver);
const spy = driver.connectOverCDP as ReturnType<typeof vi.fn>;
expect(spy.mock.calls[0]?.[0]).toBe("http://localhost:9222/");
});
it("accepts a fully omitted options bundle (default arg)", {
timeout: 5000,
}, async () => {
const browser = makeFakeBrowser();
const driver = {
connectOverCDP: vi.fn(async () => browser),
} as unknown as Pick<BrowserType, "connectOverCDP">;
const out = await connectCloakServe(undefined, driver);
expect(out).toBe(browser);
const spy = driver.connectOverCDP as ReturnType<typeof vi.fn>;
expect(spy.mock.calls[0]?.[0]).toBe("http://localhost:9222/");
});
it("wraps connection errors in CloakLaunchError", {
timeout: 5000,
}, async () => {
const driver = {
connectOverCDP: vi.fn(async () => {
throw new Error("ECONNREFUSED 127.0.0.1:9222");
}),
} as unknown as Pick<BrowserType, "connectOverCDP">;
const err = await connectCloakServe({ fingerprint: 1 }, driver).catch(
(e: unknown) => e
);
expect(err).toBeInstanceOf(CloakLaunchError);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_PROXY_INVALID
);
expect((err as CloakLaunchError).context?.["cloakServeUrl"]).toContain(
"fingerprint=1"
);
});
it("classifies generic connect errors as LAUNCH_FAILED", {
timeout: 5000,
}, async () => {
const driver = {
connectOverCDP: vi.fn(async () => {
throw new Error("kaboom");
}),
} as unknown as Pick<BrowserType, "connectOverCDP">;
const err = await connectCloakServe({}, driver).catch(
(e: unknown) => e
);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED
);
});
it("falls back to the @playwright/test chromium when no injection", {
timeout: 10000,
}, async () => {
_resetCloakServeChromiumCache();
const browser = makeFakeBrowser();
const spy = vi.fn(async () => browser);
vi.doMock("@playwright/test", () => ({
chromium: { connectOverCDP: spy },
}));
try {
const out = await connectCloakServe({ fingerprint: 99 });
expect(out).toBe(browser);
expect(spy).toHaveBeenCalled();
} finally {
vi.doUnmock("@playwright/test");
_resetCloakServeChromiumCache();
}
});
it("caches the dynamic-imported chromium across calls", {
timeout: 10000,
}, async () => {
_resetCloakServeChromiumCache();
const browser = makeFakeBrowser();
const spy = vi.fn(async () => browser);
vi.doMock("@playwright/test", () => ({
chromium: { connectOverCDP: spy },
}));
try {
await connectCloakServe({ fingerprint: 1 });
await connectCloakServe({ fingerprint: 2 });
expect(spy).toHaveBeenCalledTimes(2);
} finally {
vi.doUnmock("@playwright/test");
_resetCloakServeChromiumCache();
}
});
});

View File

@@ -0,0 +1,48 @@
import type { Browser, BrowserType } from "@playwright/test";
import { wrapCloakError } from "../errors/testing-e2e-error.js";
import { buildCloakServeUrl } from "./build-cloakserve-url.js";
import type { CloakServeConnectOptions } from "./cloakserve-options.types.js";
/**
* Connect to a running `cloakserve` CDP multiplexer and return a
* Playwright `Browser`. The remote container spawns (or reuses) a
* stealth Chromium process whose fingerprint is derived from the
* query parameters in `options`.
*
* `chromium` is injected (defaults to `@playwright/test`'s
* `chromium`) so tests can supply a spy without involving the real
* Playwright runtime.
*
* Connection failures are wrapped in a `CloakLaunchError` with a
* classified `TESTING_E2E.CLOAK.*` code.
*/
export async function connectCloakServe(
options: CloakServeConnectOptions = {},
chromium?: Pick<BrowserType, "connectOverCDP">
): Promise<Browser> {
const url = buildCloakServeUrl(options);
const driver = chromium ?? (await resolveDefaultChromium());
try {
return await driver.connectOverCDP(url);
} catch (cause) {
throw wrapCloakError(cause, { cloakServeUrl: url });
}
}
let cachedChromium: Pick<BrowserType, "connectOverCDP"> | undefined;
async function resolveDefaultChromium(): Promise<
Pick<BrowserType, "connectOverCDP">
> {
if (cachedChromium) return cachedChromium;
const mod = (await import("@playwright/test")) as unknown as {
chromium: Pick<BrowserType, "connectOverCDP">;
};
cachedChromium = mod.chromium;
return cachedChromium;
}
/** @internal Reset the cached chromium import (unit-test hook). */
export function _resetCloakServeChromiumCache(): void {
cachedChromium = undefined;
}

View File

@@ -0,0 +1,50 @@
/**
* Query-parameter shape understood by cloakserve's CDP multiplexer.
* Mirrors the parameter list documented in
* https://github.com/CloakHQ/CloakBrowser#docker-compose.
*
* Each unique `fingerprint` seed spawns a separate Chromium process
* inside the cloakserve container; the first connection for a given
* seed wins on the other params.
*/
export interface CloakServeFingerprint {
/** Master fingerprint seed (integer-as-string). Same seed → same identity across launches. */
readonly fingerprint?: string | number;
/** IANA timezone (`America/New_York`). */
readonly timezone?: string;
/** BCP-47 locale (`en-US`). */
readonly locale?: string;
/** `windows` | `macos` | `linux`. */
readonly platform?: "windows" | "macos" | "linux";
/** Client Hints platform version (`15.0`). */
readonly platformVersion?: string;
/** Browser brand (`Chrome` | `Edge` | `Opera` | `Vivaldi`). */
readonly brand?: string;
/** Brand version (`146.0.0.0`). */
readonly brandVersion?: string;
/** `UNMASKED_VENDOR_WEBGL`. */
readonly gpuVendor?: string;
/** `UNMASKED_RENDERER_WEBGL`. */
readonly gpuRenderer?: string;
/** `navigator.hardwareConcurrency`. */
readonly hardwareConcurrency?: number;
/** `navigator.deviceMemory` (GB). */
readonly deviceMemory?: number;
/** Screen width (px). */
readonly screenWidth?: number;
/** Screen height (px). */
readonly screenHeight?: number;
/** Proxy URL forwarded to the spawned Chromium. */
readonly proxy?: string;
/** Auto-detect timezone/locale from proxy exit IP via mmdb-lib. */
readonly geoip?: boolean;
}
export interface CloakServeConnectOptions extends CloakServeFingerprint {
/**
* Base URL of the cloakserve endpoint. Default
* `http://localhost:9222`. The query string is appended by
* {@link buildCloakServeUrl}.
*/
readonly url?: string;
}

View File

@@ -0,0 +1,9 @@
export { buildCloakServeUrl } from "./build-cloakserve-url.js";
export {
_resetCloakServeChromiumCache,
connectCloakServe,
} from "./cloakserve-connect.js";
export type {
CloakServeConnectOptions,
CloakServeFingerprint,
} from "./cloakserve-options.types.js";

View File

@@ -0,0 +1,66 @@
# create-playwright-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createPlaywrightConfig` builds a ready-to-use Playwright config object with project-wide defaults: conservative trace/screenshot/video settings for local dev, stricter retries and single-worker execution under CI, and a consistent `fullyParallel: false` to keep ordering deterministic. It exists so every app and package that runs Playwright E2E tests starts from the same baseline without copying boilerplate.
## Features
- `fullyParallel: false` — deterministic ordering across test files
- `trace: "on-first-retry"`, `screenshot: "only-on-failure"`, `video: "retain-on-failure"` defaults
- CI mode (when `process.env.CI` is truthy): `forbidOnly: true`, `retries: 2`, `workers: 1`
- Caller overrides merge over defaults without clobbering unrelated fields
- `baseURL` propagates into `use` automatically
## Usage: Basic Example
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({ baseURL: "http://localhost:3000" });
```
## Usage: Advanced Example
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
timeout: 60_000,
globalTimeout: 600_000,
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
{ name: "firefox", use: { browserName: "firefox" } },
],
use: {
// Override a default.
trace: "retain-on-failure",
},
reporter: [["list"], ["html", { open: "never" }]],
});
```
## API Reference
### `createPlaywrightConfig(options?: CreatePlaywrightConfigOptions): PlaywrightTestConfig`
- `options` — superset of `PlaywrightTestConfig` with an extra `baseURL` convenience at the top level
- Returns a fully-formed `PlaywrightTestConfig`
### Defaults applied
- `fullyParallel: false`
- `use.trace = "on-first-retry"`
- `use.screenshot = "only-on-failure"`
- `use.video = "retain-on-failure"`
- CI mode sets `forbidOnly: true`, `retries: 2`, `workers: 1`
## Implementation Notes
`baseURL` is accepted at the top level for ergonomics even though Playwright's own config puts it under `use.baseURL`. The factory forwards the top-level value into `use.baseURL` only if the caller didn't already supply one there. CI detection is a single `process.env.CI` read at call time — no env-var caching — so tests that want to simulate CI can set the variable in a `beforeEach`. Caller overrides flow through a standard rest-spread so arbitrary top-level Playwright fields (`timeout`, `globalTimeout`, `projects`, `reporter`, etc.) pass through untouched.

View File

@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from "vitest";
import { createPlaywrightConfig } from "./create-playwright-config.js";
const ORIGINAL_CI = process.env["CI"];
afterEach(() => {
if (ORIGINAL_CI === undefined) {
delete process.env["CI"];
} else {
process.env["CI"] = ORIGINAL_CI;
}
});
describe("createPlaywrightConfig — defaults", () => {
it("sets fullyParallel: false", { timeout: 5000 }, () => {
delete process.env["CI"];
const cfg = createPlaywrightConfig();
expect(cfg.fullyParallel).toBe(false);
});
it("applies trace/screenshot/video defaults via use", {
timeout: 5000,
}, () => {
delete process.env["CI"];
const cfg = createPlaywrightConfig();
const use = cfg.use as Record<string, unknown>;
expect(use.trace).toBe("on-first-retry");
expect(use.screenshot).toBe("only-on-failure");
expect(use.video).toBe("retain-on-failure");
});
it("propagates baseURL into use", { timeout: 5000 }, () => {
delete process.env["CI"];
const cfg = createPlaywrightConfig({
baseURL: "http://localhost:3000",
});
const use = cfg.use as Record<string, unknown>;
expect(use.baseURL).toBe("http://localhost:3000");
});
});
describe("createPlaywrightConfig — CI mode", () => {
it("sets forbidOnly, retries, workers in CI", { timeout: 5000 }, () => {
process.env["CI"] = "1";
const cfg = createPlaywrightConfig();
expect(cfg.forbidOnly).toBe(true);
expect(cfg.retries).toBe(2);
expect(cfg.workers).toBe(1);
});
it("respects caller overrides over CI defaults", {
timeout: 5000,
}, () => {
process.env["CI"] = "1";
const cfg = createPlaywrightConfig({ retries: 5, workers: 3 });
expect(cfg.retries).toBe(5);
expect(cfg.workers).toBe(3);
});
});
describe("createPlaywrightConfig — caller overrides", () => {
it("use overrides shadow defaults", { timeout: 5000 }, () => {
const cfg = createPlaywrightConfig({
use: { trace: "off" },
});
const use = cfg.use as Record<string, unknown>;
expect(use.trace).toBe("off");
// Non-overridden defaults still present.
expect(use.screenshot).toBe("only-on-failure");
});
it("preserves arbitrary top-level fields", { timeout: 5000 }, () => {
const cfg = createPlaywrightConfig({
timeout: 60_000,
globalTimeout: 600_000,
});
expect(cfg.timeout).toBe(60_000);
expect(cfg.globalTimeout).toBe(600_000);
});
});

View File

@@ -0,0 +1,45 @@
/**
* Factory that emits a `PlaywrightTestConfig` with WSL2/CI-safe defaults.
*
* Defaults:
* - `fullyParallel: false` — WSL2 stability
* - `use.trace: "on-first-retry"`, `use.screenshot: "only-on-failure"`,
* `use.video: "retain-on-failure"`
* - CI mode (process.env.CI truthy): `forbidOnly: true`, `retries: 2`,
* `workers: 1`
* - Caller overrides take precedence over every default.
*/
import type { PlaywrightTestConfig } from "@playwright/test";
type PlaywrightUse = NonNullable<PlaywrightTestConfig["use"]>;
export type CreatePlaywrightConfigOptions = Omit<
PlaywrightTestConfig,
"use"
> & {
readonly use?: PlaywrightUse;
/** Optional baseURL shortcut (sets `use.baseURL`). */
readonly baseURL?: string;
};
export function createPlaywrightConfig(
options: CreatePlaywrightConfigOptions = {}
): PlaywrightTestConfig {
const isCI = typeof process !== "undefined" && Boolean(process.env["CI"]);
const { baseURL, use, ...rest } = options;
const finalUse: PlaywrightUse = {
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
...(baseURL !== undefined ? { baseURL } : {}),
...use,
};
const ciDefaults = isCI ? { forbidOnly: true, retries: 2, workers: 1 } : {};
return {
fullyParallel: false,
...ciDefaults,
...rest,
use: finalUse,
};
}

View File

@@ -0,0 +1,4 @@
export {
type CreatePlaywrightConfigOptions,
createPlaywrightConfig,
} from "./create-playwright-config";

View File

@@ -0,0 +1,102 @@
# console-monitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachConsoleMonitor` attaches a listener to a Playwright `Page` and captures every console message emitted during the test. Its `assertClean()` method fails the test if any captured message matches the configured severity levels, providing an automated gate against unexpected JavaScript errors, deprecation warnings, and other console noise that would otherwise require manual log inspection. It mirrors the DevTools console experience, making CI failures straightforward to reproduce locally by opening the same URL.
The monitor is designed for test-runner-agnostic use: it collects messages into a plain array so tests can inspect them directly, and it throws a standard `Error` so any test framework can catch it without adapter libraries.
## Features
- Registers a single `page.on("console")` listener — no polling, no page evaluation
- Configurable `failOn` severity levels: `"error"`, `"warning"`, `"info"` (defaults to `["error"]`)
- Per-message ignore regexp list for suppressing known-safe console noise (e.g. React DevTools banner)
- Normalises Playwright's `"warn"` type alias to `"warning"` for consistent level matching
- `assertClean()` throws with a multi-line message listing every failing console entry
- Exposes the raw `messages` array for test-specific assertions beyond what `assertClean` covers
## Usage: Basic Example
```typescript
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test, expect } from "@playwright/test";
test("page loads without console errors", async ({ page }) => {
const monitor = attachConsoleMonitor(page);
await page.goto("http://localhost:3200/");
await page.waitForLoadState("networkidle");
monitor.assertClean();
});
```
## Usage: Advanced Example
```typescript
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test } from "@playwright/test";
test("dashboard emits no errors or warnings, ignoring React DevTools banner", async ({ page }) => {
const monitor = attachConsoleMonitor(page, {
failOn: ["error", "warning"],
ignore: [
/Download the React DevTools/,
/ReactDOM.render is no longer supported/,
],
});
await page.goto("http://localhost:3200/dashboard");
await page.waitForSelector("[data-testid=dashboard-ready]");
// Can also inspect messages directly before asserting
const warnings = monitor.messages.filter((m) => m.type() === "warning");
console.log(`Captured ${warnings.length} warnings (all ignored)`);
monitor.assertClean();
});
```
## API Reference
### `attachConsoleMonitor(page: Page, options?: ConsoleMonitorOptions): ConsoleMonitor`
Registers a console message collector on the given Playwright page.
**Parameters:**
- `page` — Playwright `Page` instance to monitor
- `options.failOn` — array of severity levels that cause `assertClean()` to throw; defaults to `["error"]`
- `options.ignore` — array of `RegExp` patterns; messages whose text matches any pattern are excluded from failure consideration regardless of severity
**Returns:** `ConsoleMonitor`
- `.messages` — read-only array of all `ConsoleMessage` instances collected since `attachConsoleMonitor` was called
- `.assertClean()` — throws `Error` if any collected message matches a `failOn` level and is not suppressed by an `ignore` pattern; the error message includes a formatted list of every offending entry
### `ConsoleMonitor` interface
```typescript
interface ConsoleMonitor {
readonly messages: ConsoleMessage[];
assertClean(): void;
}
```
### `ConsoleMonitorOptions` interface
```typescript
interface ConsoleMonitorOptions {
failOn?: Array<"error" | "warning" | "info">;
ignore?: RegExp[];
}
```
## Implementation Notes
Playwright uses `"warn"` as a type string in some browser contexts despite DevTools calling it `"warning"`. The internal `PLAYWRIGHT_TYPE_MAP` normalises `"warn"` to `"warning"` so tests using `failOn: ["warning"]` reliably catch both variants without requiring callers to know about this inconsistency.
The `messages` getter returns the live internal array directly rather than a copy. This is intentional: test authors who iterate `messages` after `assertClean()` see exactly what was collected, with no copy overhead. Since tests run synchronously relative to the listener, mutation during iteration is not a concern in practice.
Type resolution for unrecognised console types (e.g. `"debug"`, `"verbose"`, `"table"`) falls back to `"info"`, which means they are only included in failures if `failOn` explicitly includes `"info"`.

View File

@@ -0,0 +1,132 @@
import type { ConsoleMessage, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
function fakeMsg(type: string, text: string): ConsoleMessage {
return { type: () => type, text: () => text } as unknown as ConsoleMessage;
}
function makeFakePage() {
const listeners = new Map<string, ((msg: ConsoleMessage) => void)[]>();
const page = {
on: vi.fn((event: string, cb: (msg: ConsoleMessage) => void) => {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(cb);
}),
} as unknown as Page;
function emit(msg: ConsoleMessage) {
for (const cb of listeners.get("console") ?? []) cb(msg);
}
return { page, emit };
}
describe("attachConsoleMonitor", () => {
it("registers console listener on the page", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page } = makeFakePage();
attachConsoleMonitor(page);
expect(page.on as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"console",
expect.any(Function)
);
});
it("collects console messages emitted on the page", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page);
emit(fakeMsg("log", "hello"));
expect(monitor.messages).toHaveLength(1);
expect(monitor.messages[0]!.text()).toBe("hello");
});
it("assertClean does not throw when no failOn messages", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
emit(fakeMsg("log", "safe message"));
expect(() => monitor.assertClean()).not.toThrow();
});
it("assertClean throws when an error-level message is collected", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
emit(fakeMsg("error", "uncaught TypeError"));
expect(() => monitor.assertClean()).toThrow("uncaught TypeError");
});
it("assertClean ignores messages matching ignore regexps", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, {
failOn: ["error"],
ignore: [/Download the React DevTools/],
});
emit(
fakeMsg(
"error",
"Download the React DevTools for a better development experience"
)
);
expect(() => monitor.assertClean()).not.toThrow();
});
it("defaults failOn to [error] when not specified", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page);
emit(fakeMsg("error", "default failOn error"));
expect(() => monitor.assertClean()).toThrow("default failOn error");
});
it("collects warning messages when failOn includes warning", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
emit(fakeMsg("warning", "deprecation warning"));
expect(() => monitor.assertClean()).toThrow("deprecation warning");
});
it("normalises warn type to warning", { timeout: 5000 }, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["warning"] });
emit(fakeMsg("warn", "normalised warn"));
expect(() => monitor.assertClean()).toThrow("normalised warn");
});
it("assertClean lists all failing messages in the error text", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["error"] });
emit(fakeMsg("error", "first error"));
emit(fakeMsg("error", "second error"));
expect(() => monitor.assertClean()).toThrow(/first error/);
});
it("unmapped type falls back to info level", {
timeout: 5000,
}, async () => {
const { attachConsoleMonitor } = await import("./console-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachConsoleMonitor(page, { failOn: ["info"] });
emit(fakeMsg("debug", "verbose debug message"));
expect(() => monitor.assertClean()).toThrow("verbose debug message");
});
});

View File

@@ -0,0 +1,48 @@
import type { ConsoleMessage, Page } from "@playwright/test";
import type {
ConsoleMonitor,
ConsoleMonitorOptions,
} from "./console-monitor.types.js";
const PLAYWRIGHT_TYPE_MAP: Record<string, "error" | "warning" | "info"> = {
error: "error",
warning: "warning",
warn: "warning",
info: "info",
log: "info",
};
export function attachConsoleMonitor(
page: Page,
options: ConsoleMonitorOptions = {}
): ConsoleMonitor {
const failOn = options.failOn ?? ["error"];
const ignore = options.ignore ?? [];
const messages: ConsoleMessage[] = [];
page.on("console", (msg: ConsoleMessage) => {
messages.push(msg);
});
return {
get messages() {
return messages;
},
assertClean() {
const failing = messages.filter((msg) => {
const level = PLAYWRIGHT_TYPE_MAP[msg.type()] ?? "info";
if (!failOn.includes(level)) return false;
const text = msg.text();
return !ignore.some((re) => re.test(text));
});
if (failing.length > 0) {
const lines = failing
.map((m) => ` [${m.type()}] ${m.text()}`)
.join("\n");
throw new Error(
`ConsoleMonitor: unexpected console messages:\n${lines}`
);
}
},
};
}

View File

@@ -0,0 +1,11 @@
import type { ConsoleMessage } from "@playwright/test";
export interface ConsoleMonitorOptions {
failOn?: Array<"error" | "warning" | "info">;
ignore?: RegExp[];
}
export interface ConsoleMonitor {
readonly messages: ConsoleMessage[];
assertClean(): void;
}

View File

@@ -0,0 +1,5 @@
export { attachConsoleMonitor } from "./console-monitor.js";
export type {
ConsoleMonitor,
ConsoleMonitorOptions,
} from "./console-monitor.types.js";

View File

@@ -0,0 +1,79 @@
# codes
## Purpose
`TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry the `TESTING_E2E.` prefix so log filters can isolate Playwright-harness failures from application code. The registry is hand-built (not via `defineCodes`) because the package has zero `@suaveplan/*` deps by design.
## Features
- Frozen record of namespaced codes
- `TestingE2eCode` type alias for exhaustive-check support
- Stable — additions append; removals require a deprecation cycle
- Structural match for `defineCodes` output — drop-in replacement if the dep rule later relaxes
## Usage: Basic Example
```ts
import { TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT; // "TESTING_E2E.WAIT.TIMEOUT"
```
## Usage: Advanced Example
```ts
import {
TESTING_E2E_ERROR_CODES,
TestingE2eError,
type TestingE2eCode,
} from "@suaveplan/testing-e2e";
function shouldRetry(code: TestingE2eCode): boolean {
switch (code) {
case TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT:
case TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED:
return true;
case TESTING_E2E_ERROR_CODES.ABORTED:
case TESTING_E2E_ERROR_CODES.FIXTURE_FAILED:
case TESTING_E2E_ERROR_CODES.CONFIG_INVALID:
return false;
}
}
```
## API Reference
### `TESTING_E2E_ERROR_CODES`
Frozen object; every value is a dot-separated code string starting with `TESTING_E2E.`.
### `type TestingE2eCode`
Union of every `TESTING_E2E_ERROR_CODES[K]` value.
## Implementation Notes
The registry is built with `Object.freeze({ ... })` rather than `defineCodes` to keep the zero-workspace-dep invariant. Because each code is a plain string literal in the object, TypeScript's control-flow analysis narrows `err.code` correctly inside `instanceof TestingE2eError` branches. New codes append in source order; existing codes are never renumbered or reused.
### Registry Layout
Codes live under the `TESTING.E2E` namespace and are produced by `defineCodes`. Current codes cover clipboard-mock setup failures, geolocation-mock validation failures, page-object navigation timeouts, dialog-dismissal races, and fixture-extension errors. The `TestingE2eCode` union is the canonical parameter type for error-handler signatures.
### Stability
New codes are added append-only; existing values never change. Consumers that type-narrow on `error.code` using the exhaustive `TestingE2eCode` union get a compile-time error when they miss a new variant only if they use a `switch` with a `never` default branch.
### CLOAK domain
The `TESTING_E2E.CLOAK.*` family covers failures originating in the
`cloakbrowser`-backed browser launcher used by the default fixtures:
- `CLOAK_LAUNCH_FAILED` — generic launch failure (default classification).
- `CLOAK_BINARY_MISSING` — stealth Chromium binary not found or
could not be downloaded. Remediation: `npx cloakbrowser install`.
- `CLOAK_PROXY_INVALID` — proxy URL malformed, refused, or
authentication failed.
These codes pair with the `CloakLaunchError` class and the
`classifyCloakError` / `wrapCloakError` helpers exported from
`@suaveplan/testing-e2e`.

View File

@@ -0,0 +1,20 @@
/**
* Stable error-code constants for `@suaveplan/testing-e2e`.
*/
export const TESTING_E2E_ERROR_CODES = {
CONFIG_INVALID: "TESTING_E2E.CONFIG.INVALID",
NAVIGATION_FAILED: "TESTING_E2E.NAVIGATION.FAILED",
WAIT_TIMEOUT: "TESTING_E2E.WAIT.TIMEOUT",
FIXTURE_SETUP_FAILED: "TESTING_E2E.FIXTURE.SETUP_FAILED",
ABORTED: "TESTING_E2E.ABORTED",
CLOAK_LAUNCH_FAILED: "TESTING_E2E.CLOAK.LAUNCH_FAILED",
CLOAK_BINARY_MISSING: "TESTING_E2E.CLOAK.BINARY_MISSING",
CLOAK_PROXY_INVALID: "TESTING_E2E.CLOAK.PROXY_INVALID",
} as const;
export type TestingE2eErrorCode =
(typeof TESTING_E2E_ERROR_CODES)[keyof typeof TESTING_E2E_ERROR_CODES];
export const TESTING_E2E_ERROR_CODE_PATTERN =
/^TESTING_E2E\.[A-Z]+\.[A-Z_]+$|^TESTING_E2E\.ABORTED$/;

View File

@@ -0,0 +1,17 @@
export {
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
type TestingE2eErrorCode,
} from "./codes.js";
export {
AbortedError,
CloakLaunchError,
ConfigError,
classifyCloakError,
FixtureError,
NavigationError,
TestingE2eError,
type TestingE2eErrorOptions,
WaitTimeoutError,
wrapCloakError,
} from "./testing-e2e-error.js";

View File

@@ -0,0 +1,94 @@
# testing-e2e-error
## Purpose
`TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing-e2e`. Its shape mirrors `SuaveplanError` — `{ code, cause?, context? }` — but it does NOT extend it, because testing-infrastructure packages must stay free of any `@suaveplan/*` workspace dependency (rule #8 in `CLAUDE.md`).
## Features
- `code` narrowed to `TestingE2eCode` (the literal union of `TESTING_E2E.*` strings)
- Named subclasses (`ConfigError`, `NavigationError`, `WaitTimeoutError`, `FixtureError`, `AbortedError`) for targeted `instanceof` checks
- Preserves `cause` via ES2022 `Error.cause`
- Optional `context` for structured diagnostic fields
## Usage: Basic Example
```ts
import { WaitTimeoutError, TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
throw new WaitTimeoutError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
"waitForNetworkIdle: network did not settle within 30000ms"
);
```
## Usage: Advanced Example
```ts
import {
WaitTimeoutError,
NavigationError,
TestingE2eError,
TESTING_E2E_ERROR_CODES,
} from "@suaveplan/testing-e2e";
async function safeNavigate(url: string, page: Page): Promise<void> {
try {
await page.goto(url, { timeout: 10_000 });
} catch (cause) {
throw new NavigationError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
`navigation to ${url} failed`,
{ cause, context: { url } }
);
}
}
try {
await safeNavigate("https://example.com", page);
} catch (err) {
if (err instanceof TestingE2eError) {
console.error(err.code, err.context, err.cause);
}
throw err;
}
```
## API Reference
### `class TestingE2eError extends Error`
- `readonly code: TestingE2eCode`
- `readonly context?: Readonly<Record<string, unknown>>`
- `cause?: unknown` (from `Error.cause`)
### Subclasses
- `ConfigError` — config-factory rejections
- `NavigationError` — `page.goto` / routing failures
- `WaitTimeoutError` — helper wait timeouts
- `FixtureError` — fixture setup/teardown failures
- `AbortedError` — signal-driven cancellations
- `CloakLaunchError` — `cloakbrowser` launcher failures (binary
missing, proxy invalid, generic launch failure)
### Cloak helpers
- `classifyCloakError(err) → TestingE2eErrorCode` — pure synchronous
classifier mapping raw errors thrown by `cloakbrowser` to one of the
three `TESTING_E2E.CLOAK.*` codes. Returns `LAUNCH_FAILED` for any
error the classifier does not specifically recognise.
- `wrapCloakError(err, context?) → CloakLaunchError` — convenience
wrapper used by every cloakbrowser-backed fixture. Classifies the
raw error, appends a remediation hint where applicable
(`npx cloakbrowser install` for binary issues, proxy verification
for proxy errors), and preserves the original error via
`Error.cause`.
## Implementation Notes
The subclasses exist so callers can write `catch (err instanceof WaitTimeoutError)` in hot paths without a `switch` on `code`. They all share the same structural shape — the subclass is purely a type-narrowing aid, not a behavioural difference. The base class deliberately does not extend `SuaveplanError` — if the project ever relaxes the testing-infra dep rule, swapping the base is a one-line change and no caller breaks.
### Subclass Index
`TestingE2eError` is the base class. Subclasses cover `ClipboardMockError`, `GeolocationMockError`, `PageObjectTimeoutError`, and `DialogDismissalError`. Every subclass sets its own code from `TESTING_E2E_CODES`, overrides `name`, and inherits wire-serialisation + OTel span-event behaviour from `SuaveplanError` so Playwright trace viewers see a consistent error shape.

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
} from "./codes.js";
import {
AbortedError,
CloakLaunchError,
ConfigError,
classifyCloakError,
FixtureError,
NavigationError,
TestingE2eError,
WaitTimeoutError,
wrapCloakError,
} from "./testing-e2e-error.js";
describe("TestingE2eError base", () => {
it("stores code, message, cause, context", { timeout: 5000 }, () => {
const cause = new Error("u");
const err = new TestingE2eError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
"nav fail",
{ cause, context: { path: "/x" } }
);
expect(err.code).toBe("TESTING_E2E.NAVIGATION.FAILED");
expect(err.message).toBe("nav fail");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ path: "/x" });
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TestingE2eError);
});
it("omits context/cause when not provided", { timeout: 5000 }, () => {
const err = new TestingE2eError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
"x"
);
expect(err.cause).toBeUndefined();
expect(err.context).toBeUndefined();
});
});
describe("Subclasses", () => {
it("each preserves instanceof TestingE2eError", { timeout: 5000 }, () => {
const cases = [
new ConfigError(TESTING_E2E_ERROR_CODES.CONFIG_INVALID, "x"),
new NavigationError(TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED, "x"),
new WaitTimeoutError(TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT, "x"),
new FixtureError(TESTING_E2E_ERROR_CODES.FIXTURE_SETUP_FAILED, "x"),
];
for (const e of cases) expect(e).toBeInstanceOf(TestingE2eError);
});
it("AbortedError hardcodes ABORTED code", { timeout: 5000 }, () => {
expect(new AbortedError("x").code).toBe(
TESTING_E2E_ERROR_CODES.ABORTED
);
});
});
describe("Code pattern", () => {
it("every value matches the pattern", { timeout: 5000 }, () => {
for (const code of Object.values(TESTING_E2E_ERROR_CODES)) {
expect(code).toMatch(TESTING_E2E_ERROR_CODE_PATTERN);
}
});
it("registers CLOAK_* codes", { timeout: 5000 }, () => {
expect(TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
expect(TESTING_E2E_ERROR_CODES.CLOAK_BINARY_MISSING).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(TESTING_E2E_ERROR_CODES.CLOAK_PROXY_INVALID).toBe(
"TESTING_E2E.CLOAK.PROXY_INVALID"
);
});
});
describe("CloakLaunchError", () => {
it("is a TestingE2eError subclass with named brand", {
timeout: 5000,
}, () => {
const err = new CloakLaunchError(
TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED,
"boom"
);
expect(err).toBeInstanceOf(TestingE2eError);
expect(err.name).toBe("CloakLaunchError");
expect(err.code).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
});
it("CloakLaunchError exposes the LAUNCH_FAILED code by default", {
timeout: 5000,
}, () => {
const wrapped = wrapCloakError(new Error("kaboom"));
expect(wrapped).toBeInstanceOf(CloakLaunchError);
expect(wrapped.code).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
expect(wrapped.message).toContain("cloakbrowser launch failed");
expect(wrapped.message).toContain("kaboom");
});
});
describe("classifyCloakError", () => {
it("classifyCloakError maps ENOENT to BINARY_MISSING", {
timeout: 5000,
}, () => {
expect(classifyCloakError(new Error("ENOENT: missing"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(
classifyCloakError(new Error("Failed to download chromium"))
).toBe("TESTING_E2E.CLOAK.BINARY_MISSING");
expect(classifyCloakError(new Error("binary not found in cache"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(classifyCloakError("no such file or directory")).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
expect(classifyCloakError(new Error("cloakbrowser/bin x"))).toBe(
"TESTING_E2E.CLOAK.BINARY_MISSING"
);
});
it("maps proxy hints to PROXY_INVALID", { timeout: 5000 }, () => {
for (const m of [
"ECONNREFUSED 127.0.0.1:1080",
"proxy authentication required",
"Invalid proxy: malformed url",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_PROXY_CONNECTION_FAILED at hop",
]) {
expect(classifyCloakError(new Error(m))).toBe(
"TESTING_E2E.CLOAK.PROXY_INVALID"
);
}
});
it("defaults to LAUNCH_FAILED for unknown errors", {
timeout: 5000,
}, () => {
expect(classifyCloakError(new Error("kaboom"))).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
expect(classifyCloakError(42)).toBe("TESTING_E2E.CLOAK.LAUNCH_FAILED");
expect(classifyCloakError(undefined)).toBe(
"TESTING_E2E.CLOAK.LAUNCH_FAILED"
);
});
it("wrapCloakError includes remediation hint for BINARY_MISSING", {
timeout: 5000,
}, () => {
const e = wrapCloakError(new Error("ENOENT"));
expect(e.code).toBe("TESTING_E2E.CLOAK.BINARY_MISSING");
expect(e.message).toContain("npx cloakbrowser install");
});
it("wrapCloakError includes remediation hint for PROXY_INVALID", {
timeout: 5000,
}, () => {
const e = wrapCloakError(new Error("ECONNREFUSED"));
expect(e.code).toBe("TESTING_E2E.CLOAK.PROXY_INVALID");
expect(e.message).toContain("verify proxy URL credentials");
});
it("wrapCloakError accepts a string error and preserves cause", {
timeout: 5000,
}, () => {
const e = wrapCloakError("plain string error", { hint: true });
expect(e.cause).toBe("plain string error");
expect(e.context).toEqual({ hint: true });
expect(e.message).toContain("plain string error");
});
it("wrapCloakError handles non-string non-Error causes", {
timeout: 5000,
}, () => {
const e = wrapCloakError(42);
expect(e.cause).toBe(42);
expect(e.message).toContain("unknown error");
});
});

View File

@@ -0,0 +1,131 @@
/**
* Structured error hierarchy for `@suaveplan/testing-e2e`.
*
* Mirrors the shape of `@suaveplan/testing`'s `TestingError` with
* `TESTING_E2E.<DOMAIN>.<REASON>` codes. Local because Layer 0
* testing-infrastructure packages forbid `@suaveplan/*` workspace deps.
*/
import type { TestingE2eErrorCode } from "./codes.js";
export interface TestingE2eErrorOptions {
readonly cause?: unknown;
readonly context?: Record<string, unknown>;
}
export class TestingE2eError extends Error {
public override readonly name: string = "TestingE2eError";
public readonly code: TestingE2eErrorCode;
public override readonly cause: unknown;
public readonly context: Record<string, unknown> | undefined;
constructor(
code: TestingE2eErrorCode,
message: string,
options: TestingE2eErrorOptions = {}
) {
super(message);
this.code = code;
this.cause = options.cause;
this.context = options.context;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class ConfigError extends TestingE2eError {
public override readonly name = "ConfigError";
}
export class NavigationError extends TestingE2eError {
public override readonly name = "NavigationError";
}
export class WaitTimeoutError extends TestingE2eError {
public override readonly name = "WaitTimeoutError";
}
export class FixtureError extends TestingE2eError {
public override readonly name = "FixtureError";
}
export class AbortedError extends TestingE2eError {
public override readonly name = "AbortedError";
constructor(message: string, options?: TestingE2eErrorOptions) {
super("TESTING_E2E.ABORTED", message, options);
}
}
/**
* Raised when a `cloakbrowser` launch (any flavour: `launch`,
* `launchContext`, `launchPersistentContext`) fails. `code` carries the
* specific reason classified by {@link classifyCloakError}; `cause`
* holds the original error thrown by cloakbrowser or its underlying
* playwright-core driver.
*/
export class CloakLaunchError extends TestingE2eError {
public override readonly name = "CloakLaunchError";
}
const BINARY_HINTS = [
"binary not found",
"ENOENT",
"Failed to download",
"cloakbrowser/bin",
"no such file",
] as const;
const PROXY_HINTS = [
"ECONNREFUSED",
"proxy authentication required",
"Invalid proxy",
"ERR_TUNNEL_CONNECTION_FAILED",
"ERR_PROXY_CONNECTION_FAILED",
] as const;
/**
* Map a raw error thrown by cloakbrowser to one of the three CLOAK
* codes. Returns `LAUNCH_FAILED` as the default when no classifier
* matches. Pure and synchronous — caller is responsible for wrapping
* the result into a {@link CloakLaunchError}.
*/
export function classifyCloakError(err: unknown): TestingE2eErrorCode {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "";
if (BINARY_HINTS.some((h) => message.includes(h))) {
return "TESTING_E2E.CLOAK.BINARY_MISSING";
}
if (PROXY_HINTS.some((h) => message.includes(h))) {
return "TESTING_E2E.CLOAK.PROXY_INVALID";
}
return "TESTING_E2E.CLOAK.LAUNCH_FAILED";
}
/**
* Convenience helper: wrap an arbitrary error into a
* {@link CloakLaunchError} with a classified code. Used by the
* cloakbrowser-backed fixtures so every launch failure surfaces as a
* structured `TestingE2eError`.
*/
export function wrapCloakError(
err: unknown,
context?: Record<string, unknown>
): CloakLaunchError {
const code = classifyCloakError(err);
const original =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "unknown error";
const remediation =
code === "TESTING_E2E.CLOAK.BINARY_MISSING"
? " (run `npx cloakbrowser install` to fetch the stealth Chromium binary)"
: code === "TESTING_E2E.CLOAK.PROXY_INVALID"
? " (verify proxy URL credentials and reachability)"
: "";
return new CloakLaunchError(
code,
`cloakbrowser launch failed: ${original}${remediation}`,
context === undefined ? { cause: err } : { cause: err, context }
);
}

View File

@@ -0,0 +1,6 @@
export { launchWithExtension } from "./launch-with-extension.js";
export type {
CloakLaunchPersistentContext,
ExtensionFixtureOptions,
ExtensionFixtureResult,
} from "./launch-with-extension.types.js";

View File

@@ -0,0 +1,175 @@
# launch-with-extension
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`launchWithExtension` starts a stealth Chromium persistent browser
context with an unpacked browser extension loaded, then resolves the
extension's runtime ID by inspecting the background service worker
page URL. It is the foundational fixture for browser-extension E2E
tests: without the extension ID, tests cannot construct
`chrome-extension://` URLs to open popup or options pages.
Under the hood the launcher is `cloakbrowser.launchPersistentContext`
— the same stealth Chromium with 49 source-level fingerprint patches
that powers every other fixture in this package. The launcher is
injectable so unit tests can supply a spy and so adapters
(profile-manager, remote browser pool) can replace it without forking
this file.
## Features
- Defaults to `cloakbrowser.launchPersistentContext`; pass
`launchPersistentContext` to override
- Sets `--disable-extensions-except` and `--load-extension` flags
- Forwards arbitrary `launchOptions` (proxy, humanize, geoip,
timezone, locale, …) straight through to the launcher
- Checks `context.backgroundPages()` first; falls back to
`waitForEvent("backgroundpage")` for extensions with deferred
registration
- Extracts the extension ID from the `chrome-extension://<id>/...`
URL via a strict regex
- Classifies launcher failures via `wrapCloakError`, producing a
`CloakLaunchError` with `TESTING_E2E.CLOAK.*` code
## Usage: Basic Example
```typescript
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import path from "node:path";
const extensionPath = path.resolve(__dirname, "../../dist/extension");
const userDataDir = path.resolve(__dirname, "../../tmp/user-data");
const { context, extensionId } = await launchWithExtension({
extensionPath,
userDataDir,
});
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
// ... test assertions ...
await context.close();
```
## Usage: Advanced Example
```typescript
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { test, expect } from "@suaveplan/testing-e2e";
import path from "node:path";
const EXT_PATH = path.resolve(__dirname, "../../dist/extension");
test.describe("Browser extension popup behind anti-bot wall", () => {
let extensionId: string;
let context: import("@playwright/test").BrowserContext;
test.beforeAll(async () => {
const fixture = await launchWithExtension({
extensionPath: EXT_PATH,
userDataDir: `/tmp/ext-test-${Date.now()}`,
launchOptions: {
proxy: "socks5://user:pass@residential-proxy:1080",
geoip: true,
humanize: true,
locale: "en-US",
},
});
context = fixture.context;
extensionId = fixture.extensionId;
});
test("popup renders inside the gated app", async () => {
const page = await context.newPage();
await page.goto("https://app.example.com");
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(
page.getByRole("button", { name: "Sign in" })
).toBeVisible();
});
test.afterAll(async () => {
await context.close();
});
});
```
## API Reference
### `launchWithExtension(options: ExtensionFixtureOptions): Promise<ExtensionFixtureResult>`
Launches a persistent Chromium context with the specified extension
loaded and resolves the extension's runtime ID.
**Parameters:**
- `options.extensionPath` — absolute path to the unpacked extension
directory containing `manifest.json`
- `options.userDataDir` — absolute path to the persistent browser
profile directory; must be writable and unique per concurrent test
run
- `options.launchPersistentContext` — *optional* launcher override.
Defaults to `cloakbrowser.launchPersistentContext` (loaded
lazily via dynamic `import`). Tests inject a spy here; adapters
inject a profile-manager-backed launcher
- `options.launchOptions` — *optional* extra options forwarded
unchanged to the launcher. Use this to set `proxy`, `humanize`,
`geoip`, `timezone`, `locale`, or any other cloakbrowser
`LaunchPersistentContextOptions` field
**Returns:** `ExtensionFixtureResult`
- `.context` — the `BrowserContext` instance; caller closes it
- `.extensionId` — the lowercase alphanumeric extension ID extracted
from the background page URL
**Throws:**
- `CloakLaunchError` (`TESTING_E2E.CLOAK.*`) when the underlying
launcher fails — code is one of `LAUNCH_FAILED`, `BINARY_MISSING`,
or `PROXY_INVALID`. `context` carries `{ extensionPath, userDataDir }`
- `Error("Could not extract extension ID …")` when the background
page URL does not match the `chrome-extension://` pattern
### `ExtensionFixtureOptions` interface
```typescript
interface ExtensionFixtureOptions {
extensionPath: string;
userDataDir: string;
launchPersistentContext?: CloakLaunchPersistentContext;
launchOptions?: Record<string, unknown>;
}
```
### `ExtensionFixtureResult` interface
```typescript
interface ExtensionFixtureResult {
context: BrowserContext;
extensionId: string;
}
```
## Implementation Notes
The `headless: false` option is hard-set: headless Chromium silently
ignores `--load-extension`, so loading an extension always opens a
visible window. CI environments must run under a virtual framebuffer
(Xvfb) or a Linux headed runner.
The extension ID regex `/^chrome-extension:\/\/([a-z0-9]+)\//`
matches only lowercase alphanumeric characters — Chromium generates
the ID from the extension's public-key hash. IDs are 32 characters
in practice but the regex stays length-flexible for forward
compatibility.
The default `launchPersistentContext` is resolved via a one-time
dynamic `import("cloakbrowser")`. The cache is module-private; tests
reset it via the internal `_resetDefaultLauncherCache()` export so
the `vi.doMock("cloakbrowser", …)` hook in the unit test can swap
the implementation on a fresh load.

View File

@@ -0,0 +1,226 @@
import type { BrowserContext, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { CloakLaunchError, TESTING_E2E_ERROR_CODES } from "../errors/index.js";
import type { CloakLaunchPersistentContext } from "./launch-with-extension.types.js";
function makeFakePage(url: string): Page {
return { url: () => url } as unknown as Page;
}
function makeFakeContext(backgroundUrl: string) {
return {
backgroundPages: vi.fn(() => [makeFakePage(backgroundUrl)]),
waitForEvent: vi.fn(async () => makeFakePage(backgroundUrl)),
newPage: vi.fn(async () => makeFakePage("about:blank")),
} as unknown as BrowserContext;
}
describe("launchWithExtension", () => {
it("uses injected launcher when provided", { timeout: 30000 }, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const id = "abcdefghijklmnopqrstuvwxyz012345";
const context = makeFakeContext(
`chrome-extension://${id}/_generated_background_page.html`
);
const launcher = vi.fn(
async () => context
) as unknown as CloakLaunchPersistentContext;
const spy = launcher as ReturnType<typeof vi.fn>;
await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: launcher,
});
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
userDataDir: "/tmp/ud",
headless: false,
args: expect.arrayContaining([
"--disable-extensions-except=/tmp/ext",
"--load-extension=/tmp/ext",
]),
})
);
});
it("forwards launchOptions to the launcher", {
timeout: 30000,
}, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const id = "abcdefghijklmnopqrstuvwxyz012345";
const context = makeFakeContext(
`chrome-extension://${id}/_generated_background_page.html`
);
const launcher = vi.fn(
async () => context
) as unknown as CloakLaunchPersistentContext;
const spy = launcher as ReturnType<typeof vi.fn>;
await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: launcher,
launchOptions: {
proxy: "socks5://u:p@h:1080",
humanize: true,
geoip: true,
},
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
proxy: "socks5://u:p@h:1080",
humanize: true,
geoip: true,
})
);
});
it("extracts extensionId from chrome-extension:// background page URL", {
timeout: 30000,
}, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const id = "abcdefghijklmnopqrstuvwxyz012345";
const context = makeFakeContext(
`chrome-extension://${id}/_generated_background_page.html`
);
const result = await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: (async () =>
context) as CloakLaunchPersistentContext,
});
expect(result.extensionId).toBe(id);
expect(result.context).toBe(context);
});
it("falls back to waitForEvent when backgroundPages() is empty", {
timeout: 30000,
}, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const id = "zyxwvutsrqponmlkjihgfedcba987654";
const bgUrl = `chrome-extension://${id}/_generated_background_page.html`;
const context = {
backgroundPages: vi.fn(() => []),
waitForEvent: vi.fn(async () => makeFakePage(bgUrl)),
newPage: vi.fn(async () => makeFakePage("about:blank")),
} as unknown as BrowserContext;
const result = await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: (async () =>
context) as CloakLaunchPersistentContext,
});
expect(context.waitForEvent).toHaveBeenCalledWith("backgroundpage");
expect(result.extensionId).toBe(id);
});
it("throws when background page URL does not match chrome-extension:// pattern", {
timeout: 30000,
}, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const context = {
backgroundPages: vi.fn(() => [makeFakePage("about:blank")]),
waitForEvent: vi.fn(async () => makeFakePage("about:blank")),
} as unknown as BrowserContext;
await expect(
launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: (async () =>
context) as CloakLaunchPersistentContext,
})
).rejects.toThrow("Could not extract extension ID");
});
it("wraps launcher errors with CloakLaunchError", {
timeout: 30000,
}, async () => {
const { launchWithExtension } = await import(
"./launch-with-extension.js"
);
const launcher = (async () => {
throw new Error("ENOENT: cloakbrowser/bin missing");
}) as CloakLaunchPersistentContext;
const err = await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
launchPersistentContext: launcher,
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(CloakLaunchError);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_BINARY_MISSING
);
expect((err as CloakLaunchError).context).toEqual({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
});
});
it("defaults to cloakbrowser launchPersistentContext", {
timeout: 30000,
}, async () => {
const { launchWithExtension, _resetDefaultLauncherCache } =
await import("./launch-with-extension.js");
_resetDefaultLauncherCache();
const id = "defaultlauncherzzzzzzzzzzzzzzzzzz";
const context = makeFakeContext(
`chrome-extension://${id}/_generated_background_page.html`
);
const fakeLauncher = vi.fn(async () => context);
vi.doMock("cloakbrowser", () => ({
launchPersistentContext: fakeLauncher,
}));
try {
const result = await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
});
expect(fakeLauncher).toHaveBeenCalledTimes(1);
expect(result.extensionId).toBe(id);
} finally {
vi.doUnmock("cloakbrowser");
_resetDefaultLauncherCache();
}
});
it("caches the dynamic-imported launcher across calls", {
timeout: 30000,
}, async () => {
const { launchWithExtension, _resetDefaultLauncherCache } =
await import("./launch-with-extension.js");
_resetDefaultLauncherCache();
const id = "cachedlauncherzzzzzzzzzzzzzzzzzzz";
const context = makeFakeContext(
`chrome-extension://${id}/_generated_background_page.html`
);
const fakeLauncher = vi.fn(async () => context);
vi.doMock("cloakbrowser", () => ({
launchPersistentContext: fakeLauncher,
}));
try {
await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud",
});
await launchWithExtension({
extensionPath: "/tmp/ext",
userDataDir: "/tmp/ud2",
});
// Dynamic import happens once; both calls reuse the cached fn.
expect(fakeLauncher).toHaveBeenCalledTimes(2);
} finally {
vi.doUnmock("cloakbrowser");
_resetDefaultLauncherCache();
}
});
});

View File

@@ -0,0 +1,80 @@
import type { BrowserContext, Page } from "@playwright/test";
import { wrapCloakError } from "../errors/testing-e2e-error.js";
import type {
CloakLaunchPersistentContext,
ExtensionFixtureOptions,
ExtensionFixtureResult,
} from "./launch-with-extension.types.js";
const EXTENSION_ID_RE = /^chrome-extension:\/\/([a-z0-9]+)\//;
let cachedDefaultLauncher: CloakLaunchPersistentContext | undefined;
async function resolveDefaultLauncher(): Promise<CloakLaunchPersistentContext> {
if (cachedDefaultLauncher) return cachedDefaultLauncher;
const mod = (await import("cloakbrowser")) as unknown as {
launchPersistentContext: CloakLaunchPersistentContext;
};
cachedDefaultLauncher = mod.launchPersistentContext;
return cachedDefaultLauncher;
}
/**
* Launch a stealth Chromium with a Chrome extension loaded.
*
* Defaults to `cloakbrowser.launchPersistentContext` for the launch;
* pass `launchPersistentContext` to override (used by unit tests and
* by callers who want to swap in a profile-manager).
*
* Launcher failures are classified and wrapped in a `CloakLaunchError`
* carrying one of the `TESTING_E2E.CLOAK.*` codes.
*/
export async function launchWithExtension(
options: ExtensionFixtureOptions
): Promise<ExtensionFixtureResult> {
const {
extensionPath,
userDataDir,
launchPersistentContext,
launchOptions,
} = options;
const launcher =
launchPersistentContext ?? (await resolveDefaultLauncher());
let context: BrowserContext;
try {
context = await launcher({
userDataDir,
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
...launchOptions,
});
} catch (cause) {
throw wrapCloakError(cause, { extensionPath, userDataDir });
}
const backgroundPage = await getBackgroundPage(context);
const match = EXTENSION_ID_RE.exec(backgroundPage.url());
if (!match?.[1]) {
throw new Error(
`Could not extract extension ID from background page URL: ${backgroundPage.url()}`
);
}
return { context, extensionId: match[1] };
}
async function getBackgroundPage(context: BrowserContext): Promise<Page> {
const existing = context.backgroundPages();
if (existing.length > 0) return existing[0]!;
return context.waitForEvent("backgroundpage") as Promise<Page>;
}
/** @internal Reset the cached dynamic-import launcher (unit-test hook). */
export function _resetDefaultLauncherCache(): void {
cachedDefaultLauncher = undefined;
}

View File

@@ -0,0 +1,36 @@
import type { BrowserContext } from "@playwright/test";
/**
* Signature compatible with `cloakbrowser`'s `launchPersistentContext`.
* Re-declared structurally so the injector remains testable without
* importing the cloakbrowser package in test files.
*/
export type CloakLaunchPersistentContext = (options: {
userDataDir: string;
headless?: boolean;
args?: string[];
[key: string]: unknown;
}) => Promise<BrowserContext>;
export interface ExtensionFixtureOptions {
/** Filesystem path to the unpacked Chromium extension. */
extensionPath: string;
/** Persistent user-data directory for the launched Chromium profile. */
userDataDir: string;
/**
* Optional launcher override. Defaults to
* `cloakbrowser.launchPersistentContext`. Tests inject a spy here.
*/
launchPersistentContext?: CloakLaunchPersistentContext;
/**
* Extra options forwarded to the launcher unchanged. Use this to
* set `proxy`, `humanize`, `geoip`, `timezone`, `locale`, or any
* other cloakbrowser `LaunchPersistentContextOptions` field.
*/
launchOptions?: Record<string, unknown>;
}
export interface ExtensionFixtureResult {
context: BrowserContext;
extensionId: string;
}

View File

@@ -0,0 +1,171 @@
# extended-test
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`extendedTest` is the Playwright `test` function pre-extended with the
fixtures this package ships, and — critically — with a `browser`
worker fixture overridden to launch
[`cloakbrowser`](https://github.com/CloakHQ/cloakbrowser) instead of
the stock Playwright Chromium. Every spec built on top of this fixture
starts inside a real Chromium binary with 49 source-level fingerprint
patches: `navigator.webdriver === false`, real GPU/canvas/audio
fingerprints, normal `window.chrome`, no `HeadlessChrome` UA leak, no
CDP detectable. Cloudflare Turnstile, reCAPTCHA v3, FingerprintJS,
BrowserScan, and ShieldSquare see a normal browser.
Consumers import `test` from this module rather than `@playwright/test`
so every spec in the project starts with the same stealth fixture
surface and the same `apiContext` / `freshPage` helpers.
## Features
- `browser` (worker, overridden) — produced by
{@link launchBrowserWithCloak} backed by `cloakbrowser.launch`
- `cloak` (worker option) — cloakbrowser launch options. Default
`{ headless: true }`. Set per-project via
`use: { cloak: { headless: false, humanize: true, … } }`
- `apiContext` — a fresh `APIRequestContext` built via
`playwright.request.newContext()`; does NOT depend on `browser`,
so API-only tests never start Chromium
- `freshPage` — a brand-new browser context + page per test
- Forwards every upstream fixture; nothing is hidden or lost
- Launcher failures classify into `CloakLaunchError` with
`TESTING_E2E.CLOAK.{LAUNCH_FAILED|BINARY_MISSING|PROXY_INVALID}`
## Usage: Basic Example
```ts
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
test("login flow against an anti-bot wall", async ({ freshPage }) => {
await freshPage.goto("https://app.example.com/login");
// Cloudflare Turnstile would block stock Playwright here.
await expect(freshPage).toHaveTitle(/login/i);
});
```
## Usage: Advanced Example
```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
// Cloakbrowser launch options for the default `browser` fixture.
cloak: {
headless: true,
humanize: true,
geoip: true,
proxy: "socks5://user:pass@residential-proxy:1080",
timezone: "America/New_York",
locale: "en-US",
},
},
projects: [
{
name: "stealth-headed",
use: { cloak: { headless: false, humanize: true } },
},
{
name: "stealth-headless",
use: { cloak: { headless: true } },
},
],
});
```
```ts
// some.spec.ts
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
test("API seed + stealth UI handshake", async ({ apiContext, freshPage }) => {
const seed = await apiContext.post("/api/test/seed", {
data: { user: "alice" },
});
expect(seed.ok()).toBe(true);
await freshPage.goto("https://app.example.com/dashboard");
await expect(
freshPage.getByRole("heading", { name: /welcome alice/i })
).toBeVisible();
await apiContext.post("/api/test/reset");
});
```
```ts
// Programmatic launch outside the Playwright runner.
import {
launchBrowserWithCloak,
} from "@suaveplan/testing-e2e/fixtures";
const browser = await launchBrowserWithCloak({
humanize: true,
geoip: true,
proxy: "http://user:pass@proxy:8080",
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://bot.incolumitas.com");
console.log(await page.title());
await browser.close();
```
## API Reference
### `extendedTest`
A Playwright `test` function extended with the following fixtures:
- `browser: Browser` *(worker, overridden)* — stealth Chromium launched
via `cloakbrowser.launch`. Cleanup (`browser.close()`) runs once per
worker
- `cloak: CloakOptions` *(worker option)* — options forwarded to
`cloakbrowser.launch`. Default `{ headless: true }`
- `apiContext: APIRequestContext` — fresh per test, browser-free
- `freshPage: Page` — fresh context + page per test
- Every upstream Playwright fixture remains available
### `launchBrowserWithCloak(options?, injectedLaunch?): Promise<Browser>`
Standalone helper that powers the `browser` fixture. Use this when
you need a stealth Chromium outside the Playwright test runner (e.g.
in a vitest integration test). Pass `injectedLaunch` to swap the
launcher for testing.
### `CloakOptions`
Mirror of `cloakbrowser`'s `LaunchOptions` — `headless`, `proxy`,
`args`, `stealthArgs`, `timezone`, `locale`, `geoip`, `humanize`,
`humanPreset`, `humanConfig`, `launchOptions`.
### `expect`
Re-exported from `@playwright/test` for ergonomic one-import specs.
## Implementation Notes
The override is implemented via Playwright's `test.extend`
`browser` worker fixture slot — no private APIs touched, so upgrades
to `@playwright/test` don't require changes here. The launcher itself
is loaded via dynamic `import("cloakbrowser")` cached on first
resolution; tests reset the cache via the internal
`_resetDefaultLaunchCache()` export so `vi.doMock("cloakbrowser", …)`
can swap the implementation on a fresh load.
`apiContext` depends on Playwright's built-in `playwright` fixture
(not `browser`) so API-only tests never trigger the stealth Chromium
download or launch. The destructure pattern is pinned by a source-level
test to prevent regressions where someone adds `browser` to the
dependency list.
When `cloakbrowser.launch` throws, errors are classified by
`classifyCloakError` into one of three `TESTING_E2E.CLOAK.*` codes
and rethrown as a `CloakLaunchError`. Binary-missing failures include
the remediation hint `npx cloakbrowser install` directly in the
error message.

View File

@@ -0,0 +1,167 @@
/**
* Source-level pins:
* - `apiContext` MUST NOT request `browser` from Playwright's fixture
* deps. If it did, every API-only test would spin up a Chromium
* instance unnecessarily.
* - `browser` fixture MUST be sourced from `launchBrowserWithCloak`
* so every consumer's `browser` is the stealth `cloakbrowser`
* Chromium, not the stock Playwright Chromium.
*
* Runtime pin: `launchBrowserWithCloak` MUST classify launcher
* failures into `CloakLaunchError` with the right CLOAK code.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Browser } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { CloakLaunchError, TESTING_E2E_ERROR_CODES } from "../errors/index.js";
import {
_resetDefaultLaunchCache,
type CloakLaunchFn,
extendedTest,
launchBrowserWithCloak,
} from "./extended-test.js";
const SOURCE = readFileSync(
resolve(process.cwd(), "src/fixtures/extended-test.ts"),
"utf8"
);
describe("extendedTest source-level invariants", () => {
it("apiContext fixture does not destructure `browser`", {
timeout: 5000,
}, () => {
const apiContextBlock = SOURCE.match(
/apiContext:\s*async\s*\(\s*({[^}]*})\s*,\s*use\s*\)\s*=>/
);
expect(apiContextBlock).not.toBeNull();
const destructured = apiContextBlock?.[1] ?? "";
expect(destructured).not.toMatch(/\bbrowser\b/);
});
it("browser fixture is launched via cloakbrowser launch", {
timeout: 5000,
}, () => {
expect(SOURCE).toMatch(/launchBrowserWithCloak\(cloak\)/);
expect(SOURCE).toMatch(/import\("cloakbrowser"\)/);
});
it("cloak options from testInfo.project.use.cloak flow into launch", {
timeout: 5000,
}, () => {
expect(SOURCE).toMatch(
/cloak:\s*\[[\s\S]*?\{\s*headless:\s*true\s*\}\s*as\s+CloakOptions,[\s\S]*?\{\s*scope:\s*"worker",\s*option:\s*true\s*\}[\s\S]*?\]/
);
});
it("extendedTest is exported as a Playwright test extension", {
timeout: 5000,
}, () => {
expect(typeof extendedTest).toBe("function");
expect(typeof extendedTest.extend).toBe("function");
});
});
describe("launchBrowserWithCloak", () => {
it("uses injected launcher when provided", { timeout: 5000 }, async () => {
const fakeBrowser = { close: vi.fn() } as unknown as Browser;
const launcher = vi.fn(
async () => fakeBrowser
) as unknown as CloakLaunchFn;
const out = await launchBrowserWithCloak(
{ headless: true, humanize: true },
launcher
);
expect(out).toBe(fakeBrowser);
expect(launcher).toHaveBeenCalledWith({
headless: true,
humanize: true,
});
});
it("wraps launch errors in CloakLaunchError", {
timeout: 5000,
}, async () => {
const launcher = (async () => {
throw new Error("kaboom");
}) as CloakLaunchFn;
const err = await launchBrowserWithCloak(
{ headless: true },
launcher
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(CloakLaunchError);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_LAUNCH_FAILED
);
expect((err as CloakLaunchError).context).toEqual({
cloak: { headless: true },
});
});
it("classifies ENOENT errors as BINARY_MISSING", {
timeout: 5000,
}, async () => {
const launcher = (async () => {
throw new Error("ENOENT cloakbrowser/bin");
}) as CloakLaunchFn;
const err = await launchBrowserWithCloak({}, launcher).catch(
(e: unknown) => e
);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_BINARY_MISSING
);
expect((err as CloakLaunchError).message).toContain(
"npx cloakbrowser install"
);
});
it("classifies proxy refused errors as PROXY_INVALID", {
timeout: 5000,
}, async () => {
const launcher = (async () => {
throw new Error("ECONNREFUSED 127.0.0.1:1080");
}) as CloakLaunchFn;
const err = await launchBrowserWithCloak(
{ proxy: "socks5://127.0.0.1:1080" },
launcher
).catch((e: unknown) => e);
expect((err as CloakLaunchError).code).toBe(
TESTING_E2E_ERROR_CODES.CLOAK_PROXY_INVALID
);
});
it("falls back to dynamic-imported cloakbrowser.launch when no injection", {
timeout: 10000,
}, async () => {
_resetDefaultLaunchCache();
const fakeBrowser = { close: vi.fn() } as unknown as Browser;
const spy = vi.fn(async () => fakeBrowser);
vi.doMock("cloakbrowser", () => ({ launch: spy }));
try {
const out = await launchBrowserWithCloak({ headless: true });
expect(out).toBe(fakeBrowser);
expect(spy).toHaveBeenCalledWith({ headless: true });
} finally {
vi.doUnmock("cloakbrowser");
_resetDefaultLaunchCache();
}
});
it("caches the dynamic-imported launcher across calls", {
timeout: 10000,
}, async () => {
_resetDefaultLaunchCache();
const fakeBrowser = { close: vi.fn() } as unknown as Browser;
const spy = vi.fn(async () => fakeBrowser);
vi.doMock("cloakbrowser", () => ({ launch: spy }));
try {
await launchBrowserWithCloak({ headless: true });
await launchBrowserWithCloak({ headless: false });
expect(spy).toHaveBeenCalledTimes(2);
} finally {
vi.doUnmock("cloakbrowser");
_resetDefaultLaunchCache();
}
});
});

View File

@@ -0,0 +1,131 @@
/**
* `extendedTest` — Playwright `test.extend(...)` providing the standard
* fixture set every Suaveplan E2E suite gets, with a stealth-Chromium
* `browser` worker fixture backed by `cloakbrowser`.
*
* Fixtures:
* - `browser` (worker, overridden): launched via
* {@link launchBrowserWithCloak} — `cloakbrowser.launch` under the
* hood, so navigator.webdriver / canvas / WebGL / audio / GPU /
* screen / WebRTC / CDP signals all read as a real browser
* - `cloak` (worker option): cloakbrowser launch options (`humanize`,
* `proxy`, `geoip`, `timezone`, `locale`, …). Default
* `{ headless: true }`. Override per project via
* `use: { cloak: { headless: false, … } }` in `playwright.config.ts`
* - `apiContext`: a fresh `APIRequestContext`, useful for direct API
* calls alongside browser interactions — created via
* `playwright.request.newContext()` so it depends on the
* `playwright` built-in fixture (not on `browser`)
* - `freshPage`: a brand-new browser context + page, isolated from any
* pre-authenticated fixture
*/
import {
type APIRequestContext,
type Browser,
test as baseTest,
type Page,
} from "@playwright/test";
import { wrapCloakError } from "../errors/testing-e2e-error.js";
export interface CloakOptions {
readonly headless?: boolean;
readonly proxy?:
| string
| {
server: string;
bypass?: string;
username?: string;
password?: string;
};
readonly args?: readonly string[];
readonly stealthArgs?: boolean;
readonly timezone?: string;
readonly locale?: string;
readonly geoip?: boolean;
readonly humanize?: boolean;
readonly humanPreset?: "default" | "careful";
readonly humanConfig?: Record<string, unknown>;
readonly launchOptions?: Record<string, unknown>;
}
export type CloakLaunchFn = (options?: CloakOptions) => Promise<Browser>;
let cachedDefaultLaunch: CloakLaunchFn | undefined;
async function resolveDefaultLaunch(): Promise<CloakLaunchFn> {
if (cachedDefaultLaunch) return cachedDefaultLaunch;
const mod = (await import("cloakbrowser")) as unknown as {
launch: CloakLaunchFn;
};
cachedDefaultLaunch = mod.launch;
return cachedDefaultLaunch;
}
/** @internal Reset the dynamic-import cache (unit-test hook). */
export function _resetDefaultLaunchCache(): void {
cachedDefaultLaunch = undefined;
}
/**
* Launch a stealth Chromium `Browser` via `cloakbrowser`.
*
* Pure helper extracted from the `browser` worker fixture so it is
* unit-testable from vitest without the Playwright test runner. The
* fixture is a one-line wrapper that delegates here.
*
* Launcher failures are classified by `classifyCloakError` and
* wrapped in a `CloakLaunchError`.
*/
export async function launchBrowserWithCloak(
options?: CloakOptions,
injectedLaunch?: CloakLaunchFn
): Promise<Browser> {
const launch = injectedLaunch ?? (await resolveDefaultLaunch());
try {
return await launch(options);
} catch (cause) {
throw wrapCloakError(cause, { cloak: options });
}
}
interface ExtendedFixtures {
readonly apiContext: APIRequestContext;
readonly freshPage: Page;
}
interface ExtendedWorkerFixtures {
readonly cloak: CloakOptions;
}
export const extendedTest = baseTest.extend<
ExtendedFixtures,
ExtendedWorkerFixtures
>({
cloak: [
{ headless: true } as CloakOptions,
{ scope: "worker", option: true },
],
browser: [
async ({ cloak }, use) => {
const browser = await launchBrowserWithCloak(cloak);
await use(browser);
await browser.close();
},
{ scope: "worker" },
],
apiContext: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext();
await use(ctx);
await ctx.dispose();
},
freshPage: async ({ browser }: { browser: Browser }, use) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await use(page);
await page.close();
await ctx.close();
},
});
export { expect } from "@playwright/test";

View File

@@ -0,0 +1,8 @@
export {
_resetDefaultLaunchCache,
type CloakLaunchFn,
type CloakOptions,
expect,
extendedTest,
launchBrowserWithCloak,
} from "./extended-test.js";

View File

@@ -0,0 +1,59 @@
# dismiss-dialog
## Purpose
`dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `accept` and `dismiss`. It's the small piece of glue that turns Playwright's event-driven dialog model into an imperative one-liner, which matches how most tests actually think about modal dialogs.
## Features
- Installs a single `page.on("dialog", …)` listener; auto-removes after it fires
- Choice of `accept` (OK/Allow) or `dismiss` (Cancel/Deny)
- Safe to register before the action that triggers the dialog
- Returns an unsubscribe function so the test can detach manually if the dialog never appears
## Usage: Basic Example
```ts
import { dismissDialog } from "@suaveplan/testing-e2e/helpers";
const off = dismissDialog(page, "accept");
await page.click("text=Delete");
// If a confirm() pops, it is accepted.
off(); // optional — the handler auto-removes after firing
```
## Usage: Advanced Example
```ts
import { dismissDialog } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("destructive action requires confirm", async ({ page }) => {
await page.goto("/account");
dismissDialog(page, "dismiss"); // cancel the confirm
await page.click("text=Delete account");
await expect(page.getByRole("alert")).toHaveText(/cancelled/i);
dismissDialog(page, "accept"); // now accept it
await page.click("text=Delete account");
await expect(page).toHaveURL("/goodbye");
});
```
## API Reference
### `dismissDialog(page, action): () => void`
- `page: Page` — Playwright page
- `action: "accept" | "dismiss"` — what to do when the dialog appears
- Returns an unsubscribe function; safe to call after the handler has already auto-removed
## Implementation Notes
Per the project-wide guidance around browser dialogs, tests should avoid triggering dialogs when possible — they block the entire Playwright event stream until dismissed. `dismissDialog` is for the cases where dismissal is unavoidable (e.g. testing a `beforeunload` prompt). The handler uses `{ once: true }` semantics so a stale handler from a prior test never intercepts the current one.
### Race Protection
`dismissDialog(page)` registers a one-shot `page.once("dialog", d => d.dismiss())` listener and immediately returns a disposer that removes the listener if the dialog never fires. Doing it as one-shot avoids leaking listeners across test boundaries — a repeated bug in the previous iteration. The helper also logs the dialog's `type` and `message` through the structured logger so trace viewers show exactly what was dismissed.

View File

@@ -0,0 +1,80 @@
import type { Dialog, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { dismissDialog } from "./dismiss-dialog.js";
function makePage(): {
page: Page;
fireDialog(dialog: Dialog): Promise<void>;
} {
const handlers = new Set<(d: Dialog) => Promise<void>>();
const page = {
on(event: string, handler: (d: Dialog) => Promise<void>) {
if (event === "dialog") handlers.add(handler);
},
off(event: string, handler: (d: Dialog) => Promise<void>) {
if (event === "dialog") handlers.delete(handler);
},
} as unknown as Page;
return {
page,
async fireDialog(dialog) {
const snapshot = Array.from(handlers);
for (const h of snapshot) {
await h(dialog);
}
},
};
}
describe("dismissDialog", () => {
it("accepts the next dialog by default", { timeout: 5000 }, async () => {
const { page, fireDialog } = makePage();
const accept = vi.fn(async () => undefined);
const dialog = {
accept,
dismiss: vi.fn(async () => undefined),
} as unknown as Dialog;
dismissDialog(page);
await fireDialog(dialog);
expect(accept).toHaveBeenCalledOnce();
});
it("dismisses when action is 'dismiss'", { timeout: 5000 }, async () => {
const { page, fireDialog } = makePage();
const dismiss = vi.fn(async () => undefined);
const dialog = {
accept: vi.fn(async () => undefined),
dismiss,
} as unknown as Dialog;
dismissDialog(page, { action: "dismiss" });
await fireDialog(dialog);
expect(dismiss).toHaveBeenCalledOnce();
});
it("forwards promptText to accept", { timeout: 5000 }, async () => {
const { page, fireDialog } = makePage();
const accept = vi.fn(async (_t?: string) => undefined);
const dialog = {
accept,
dismiss: vi.fn(),
} as unknown as Dialog;
dismissDialog(page, { promptText: "alice" });
await fireDialog(dialog);
expect(accept).toHaveBeenCalledWith("alice");
});
it("only fires once (one-shot handler)", {
timeout: 5000,
}, async () => {
const { page, fireDialog } = makePage();
const accept = vi.fn(async () => undefined);
const dialog = {
accept,
dismiss: vi.fn(),
} as unknown as Dialog;
dismissDialog(page);
await fireDialog(dialog);
await fireDialog(dialog);
expect(accept).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,29 @@
/**
* Register a one-shot dialog handler on a Playwright `Page` that
* accepts (or dismisses) the next modal dialog and detaches itself.
*/
import type { Dialog, Page } from "@playwright/test";
export interface DismissDialogOptions {
/** "accept" (default) or "dismiss". */
readonly action?: "accept" | "dismiss";
/** Text to type into a `prompt` dialog before accepting. */
readonly promptText?: string;
}
export function dismissDialog(
page: Page,
options: DismissDialogOptions = {}
): void {
const action = options.action ?? "accept";
const handler = async (dialog: Dialog): Promise<void> => {
page.off("dialog", handler);
if (action === "dismiss") {
await dialog.dismiss();
} else {
await dialog.accept(options.promptText);
}
};
page.on("dialog", handler);
}

View File

@@ -0,0 +1,14 @@
export {
type DismissDialogOptions,
dismissDialog,
} from "./dismiss-dialog.js";
export { mockClipboard } from "./mock-clipboard.js";
export {
type GeolocationCoords,
mockGeolocation,
} from "./mock-geolocation.js";
export { screenshotOnFailure } from "./screenshot-on-failure.js";
export {
type WaitForNetworkIdleOptions,
waitForNetworkIdle,
} from "./wait-for-network-idle.js";

View File

@@ -0,0 +1,63 @@
# mock-clipboard
## Purpose
`mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright page. The default clipboard shipped by Chromium headless requires explicit user-gesture permissions and does not work at all in some CI environments; this helper replaces it with a programmatic pair of `writeText` / `readText` that behaves identically in every environment.
## Features
- Installs via `page.addInitScript` so the mock is active before any app code runs
- In-memory state is per-page — different pages don't share clipboard state
- Matches the real `navigator.clipboard` API surface for `writeText` and `readText`
- Idempotent — calling twice on the same page just re-installs the same script
## Usage: Basic Example
```ts
import { mockClipboard } from "@suaveplan/testing-e2e/helpers";
await mockClipboard(page);
await page.goto("/settings");
await page.click("text=Copy API key");
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toMatch(/^sk_/);
```
## Usage: Advanced Example
```ts
import { mockClipboard } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("copy + paste round trip", async ({ page }) => {
await mockClipboard(page);
await page.goto("/notes/123");
await page.getByRole("button", { name: /copy link/i }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe("https://notes.example/123");
// Paste somewhere:
await page.goto("/notes/new");
await page.evaluate(async () => {
const text = await navigator.clipboard.readText();
(document.querySelector("textarea") as HTMLTextAreaElement).value = text;
});
await expect(page.locator("textarea")).toHaveValue("https://notes.example/123");
});
```
## API Reference
### `mockClipboard(page): Promise<void>`
- `page: Page` — Playwright page (must be called before any navigation for the init script to take effect)
- Returns `Promise<void>` that resolves after the init script is registered
## Implementation Notes
The init script defines `navigator.clipboard` using `Object.defineProperty` with `writable: true` so a test that wants to swap in a further spy can still do so. Because the mock is installed via `addInitScript`, it's reinstalled automatically on every navigation within the same page — tests don't need to reinstall after `page.goto`.
### Behaviour
`mockClipboard(page)` installs an `addInitScript` that replaces `navigator.clipboard.readText` / `writeText` with in-memory stubs whose state survives page navigations inside the same context. A companion `getClipboardValue(page)` reads the current mock value synchronously from the page's JS context so assertions do not need to await a clipboard-permissions prompt.

View File

@@ -0,0 +1,35 @@
import type { Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { mockClipboard } from "./mock-clipboard.js";
describe("mockClipboard", () => {
it("calls page.addInitScript with a function", {
timeout: 5000,
}, async () => {
const addInitScript = vi.fn(async () => undefined);
const page = { addInitScript } as unknown as Page;
await mockClipboard(page);
expect(addInitScript).toHaveBeenCalledOnce();
const arg = addInitScript.mock.calls[0]?.[0];
expect(typeof arg).toBe("function");
});
it("the init script installs a programmatic clipboard on a navigator-like object", {
timeout: 5000,
}, async () => {
let capturedFn: (() => void) | undefined;
const addInitScript = vi.fn(async (fn: () => void): Promise<void> => {
capturedFn = fn;
});
const page = { addInitScript } as unknown as Page;
await mockClipboard(page);
// Invoke the captured init function in a controlled scope —
// it expects `navigator` to be defined (happy-dom provides it).
expect(capturedFn).toBeDefined();
capturedFn?.();
// After running, navigator.clipboard should be defined and
// round-trip via writeText/readText.
await navigator.clipboard.writeText("hello");
await expect(navigator.clipboard.readText()).resolves.toBe("hello");
});
});

View File

@@ -0,0 +1,22 @@
/**
* Install a programmatic clipboard on a Playwright Page so tests can
* assert what was copied without relying on the real OS clipboard.
*/
import type { Page } from "@playwright/test";
export async function mockClipboard(page: Page): Promise<void> {
await page.addInitScript(() => {
let stored = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: (text: string): Promise<void> => {
stored = text;
return Promise.resolve();
},
readText: (): Promise<string> => Promise.resolve(stored),
},
});
});
}

View File

@@ -0,0 +1,58 @@
# mock-geolocation
## Purpose
`mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware features (maps, "find nearest store", distance calculations) see deterministic input regardless of the host machine's actual location or whether the CI runner has a geolocation permission at all. It wraps Playwright's `context.grantPermissions(["geolocation"])` + `context.setGeolocation(...)` into a single call that also deals with the context-vs-page ambiguity.
## Features
- Grants `geolocation` permission on the enclosing browser context
- Sets a fixed coordinate pair
- Accepts an optional `accuracy` for the emulated reading (meters)
- Safe to call multiple times — later calls replace earlier coordinates
## Usage: Basic Example
```ts
import { mockGeolocation } from "@suaveplan/testing-e2e/helpers";
await mockGeolocation(page, { latitude: 51.5074, longitude: -0.1278 });
await page.goto("/map");
```
## Usage: Advanced Example
```ts
import { mockGeolocation } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("distance widget uses my location", async ({ page }) => {
await mockGeolocation(
page,
{ latitude: 40.7128, longitude: -74.0060, accuracy: 5 }
);
await page.goto("/stores");
await expect(page.getByRole("heading", { name: /nearest store/i }))
.toBeVisible();
await expect(page.getByTestId("distance")).toContainText("mi");
// Re-pin for a different test scenario.
await mockGeolocation(page, { latitude: 34.0522, longitude: -118.2437 });
await page.reload();
await expect(page.getByTestId("distance")).toContainText("mi");
});
```
## API Reference
### `mockGeolocation(page, coords): Promise<void>`
- `page: Page`
- `coords.latitude: number`
- `coords.longitude: number`
- `coords.accuracy?: number` — default 10 (meters)
- Returns `Promise<void>` once the permission grant + coordinate set both resolve
## Implementation Notes
Playwright distinguishes context-level and page-level geolocation; the helper works against the context behind the supplied page so the permission grant covers every page in the same context. That matters for tests that open popups or child windows — without context-level granting, the child windows see the stock "permission denied" behaviour. The `accuracy` default of 10 meters matches the order of magnitude returned by a real GPS fix and is specific enough to distinguish "pinned by a test" from "real device" if the app logs it.

View File

@@ -0,0 +1,41 @@
import type { BrowserContext } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { mockGeolocation } from "./mock-geolocation.js";
describe("mockGeolocation", () => {
it("grants permission and sets coordinates", {
timeout: 5000,
}, async () => {
const grantPermissions = vi.fn(async () => undefined);
const setGeolocation = vi.fn(async () => undefined);
const ctx = {
grantPermissions,
setGeolocation,
} as unknown as BrowserContext;
await mockGeolocation(ctx, { latitude: 51.5, longitude: -0.1 });
expect(grantPermissions).toHaveBeenCalledWith(["geolocation"]);
expect(setGeolocation).toHaveBeenCalledWith({
latitude: 51.5,
longitude: -0.1,
accuracy: 50,
});
});
it("respects caller-supplied accuracy", { timeout: 5000 }, async () => {
const setGeolocation = vi.fn(async () => undefined);
const ctx = {
grantPermissions: vi.fn(async () => undefined),
setGeolocation,
} as unknown as BrowserContext;
await mockGeolocation(ctx, {
latitude: 0,
longitude: 0,
accuracy: 5,
});
expect(setGeolocation).toHaveBeenCalledWith({
latitude: 0,
longitude: 0,
accuracy: 5,
});
});
});

View File

@@ -0,0 +1,23 @@
/**
* Install a static geolocation override on a Playwright BrowserContext.
*/
import type { BrowserContext } from "@playwright/test";
export interface GeolocationCoords {
readonly latitude: number;
readonly longitude: number;
readonly accuracy?: number;
}
export async function mockGeolocation(
context: BrowserContext,
coords: GeolocationCoords
): Promise<void> {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy ?? 50,
});
}

View File

@@ -0,0 +1,58 @@
# screenshot-on-failure
## Purpose
`screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` report so it appears inline in HTML / CI reports. It's the single best debugging affordance for flaky or environment-specific failures, and the default Playwright config (`createPlaywrightConfig`) sets `screenshot: "only-on-failure"` so the attachment is always present without a per-test opt-in.
## Features
- Uses `TestInfo.attachments` — screenshots land in the standard Playwright report
- Full-page capture (not just the viewport)
- Safe to call from an `afterEach` or `testInfo.attach` chain
- No-op when the test passed — avoids noise in green runs
## Usage: Basic Example
```ts
import { screenshotOnFailure } from "@suaveplan/testing-e2e/helpers";
import { test } from "@suaveplan/testing-e2e/fixtures";
test.afterEach(async ({ page }, testInfo) => {
await screenshotOnFailure(page, testInfo);
});
```
## Usage: Advanced Example
```ts
import { screenshotOnFailure } from "@suaveplan/testing-e2e/helpers";
import { test, expect } from "@suaveplan/testing-e2e/fixtures";
test.afterEach(async ({ page }, testInfo) => {
// Always capture state regardless of pass/fail — useful for debugging transient issues.
const snapshot = await page.screenshot({ fullPage: true });
await testInfo.attach("final-state.png", {
body: snapshot,
contentType: "image/png",
});
// Additionally, the built-in on-failure screenshot.
await screenshotOnFailure(page, testInfo);
});
test("critical flow", async ({ page }) => {
await page.goto("/checkout");
await expect(page.getByRole("heading", { name: /checkout/i })).toBeVisible();
});
```
## API Reference
### `screenshotOnFailure(page, testInfo): Promise<void>`
- `page: Page` — the Playwright page to screenshot
- `testInfo: TestInfo` — the current test's info object (available in `afterEach` / `test` fixture functions)
- Returns `Promise<void>` — completes once the attachment is written
## Implementation Notes
The `status` on `TestInfo` is `"passed"` / `"failed"` / `"timedOut"` / `"interrupted"` — the helper captures when `status !== "passed"` so flaky/hung tests also produce a screenshot. If `page` is closed by the time `afterEach` runs (e.g. a page.close() in the test body), the screenshot call rejects — the helper catches and logs rather than propagating, because failing in an `afterEach` masks the real test failure.

View File

@@ -0,0 +1,37 @@
import type { Page, TestInfo } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { screenshotOnFailure } from "./screenshot-on-failure.js";
describe("screenshotOnFailure", () => {
it("no-op when status equals expectedStatus", {
timeout: 5000,
}, async () => {
const screenshot = vi.fn(async () => Buffer.from(""));
const page = { screenshot } as unknown as Page;
const testInfo = {
status: "passed",
expectedStatus: "passed",
title: "test",
outputPath: vi.fn((s: string) => `/tmp/${s}`),
} as unknown as TestInfo;
await screenshotOnFailure(testInfo, page);
expect(screenshot).not.toHaveBeenCalled();
});
it("captures a screenshot on failure", { timeout: 5000 }, async () => {
const screenshot = vi.fn(async () => Buffer.from(""));
const page = { screenshot } as unknown as Page;
const outputPath = vi.fn((s: string) => `/tmp/${s}`);
const testInfo = {
status: "failed",
expectedStatus: "passed",
title: "broken test name!",
outputPath,
} as unknown as TestInfo;
await screenshotOnFailure(testInfo, page);
expect(screenshot).toHaveBeenCalledWith({
path: "/tmp/broken_test_name_-failure.png",
fullPage: true,
});
});
});

View File

@@ -0,0 +1,18 @@
/**
* Capture a screenshot named after the failing test. Useful as a manual
* fallback when `use.screenshot: "only-on-failure"` isn't enough.
*/
import type { Page, TestInfo } from "@playwright/test";
export async function screenshotOnFailure(
testInfo: TestInfo,
page: Page
): Promise<void> {
if (testInfo.status === testInfo.expectedStatus) return;
const safeName = testInfo.title.replace(/[^a-z0-9]+/gi, "_");
await page.screenshot({
path: testInfo.outputPath(`${safeName}-failure.png`),
fullPage: true,
});
}

View File

@@ -0,0 +1,62 @@
# wait-for-network-idle
## Purpose
`waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive milliseconds, or rejects with `WaitTimeoutError` after `timeoutMs`. It's a more permissive alternative to `page.waitForLoadState("networkidle")`, which in modern SPAs frequently times out because analytics/keepalive traffic keeps the count non-zero forever. The helper gives callers control over how quiet "quiet" has to be.
## Features
- Configurable idle window (`idleMs`, default 500) and overall timeout (`timeoutMs`, default 30_000)
- Tracks `request`, `requestfinished`, AND `requestfailed` — no hang on 4xx/5xx
- Non-negative in-flight counter via `Math.max(0, …)` — tolerates stray settle events
- Cleans up its event listeners in a `finally` block — never leaks even on reject
- Throws `WaitTimeoutError("TESTING_E2E.WAIT.TIMEOUT")` with the current in-flight count in `context`
## Usage: Basic Example
```ts
import { waitForNetworkIdle } from "@suaveplan/testing-e2e/helpers";
await page.goto("/dashboard");
await waitForNetworkIdle(page);
// Safe to query the DOM now.
```
## Usage: Advanced Example
```ts
import {
waitForNetworkIdle,
WaitTimeoutError,
TESTING_E2E_ERROR_CODES,
} from "@suaveplan/testing-e2e";
async function loadAndAssertIdle(page: Page, url: string): Promise<void> {
await page.goto(url);
try {
await waitForNetworkIdle(page, { idleMs: 1000, timeoutMs: 5000 });
} catch (err) {
if (
err instanceof WaitTimeoutError &&
err.code === TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT
) {
console.warn("page never quieted", err.context);
}
throw err;
}
}
```
## API Reference
### `waitForNetworkIdle(page, options?): Promise<void>`
- `page: Page` — a Playwright `Page` (or a duck-typed compatible shape for tests)
- `options.idleMs?: number` — default 500
- `options.timeoutMs?: number` — default 30_000
- Resolves when the idle window is met
- Rejects with `WaitTimeoutError("TESTING_E2E.WAIT.TIMEOUT")` with `context.inFlight` on timeout
## Implementation Notes
The polling loop sleeps 50ms between checks, which is a reasonable balance between responsiveness and CPU cost. The `Math.max(0, inFlight - 1)` guard tolerates the case where a stray settle event arrives without a matching request — this can happen when a service worker hijacks a request and Playwright emits only half the pair. The `finally` block always detaches the three event listeners so even a reject from the timeout path leaves the page in a clean state for later waits.

View File

@@ -0,0 +1,107 @@
import type { Page } from "@playwright/test";
import { describe, expect, it } from "vitest";
import { TESTING_E2E_ERROR_CODES } from "../errors/codes.js";
import { WaitTimeoutError } from "../errors/testing-e2e-error.js";
import { waitForNetworkIdle } from "./wait-for-network-idle.js";
type PageLike = {
on(event: string, handler: () => void): void;
off(event: string, handler: () => void): void;
};
function makePage(): { page: Page; emit(event: string): void } {
const handlers = new Map<string, Set<() => void>>();
const pageLike: PageLike = {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
},
off(event, handler) {
handlers.get(event)?.delete(handler);
},
};
return {
page: pageLike as unknown as Page,
emit(event) {
for (const h of handlers.get(event) ?? []) h();
},
};
}
describe("waitForNetworkIdle", () => {
it("resolves when network is idle for idleMs", {
timeout: 5000,
}, async () => {
const { page } = makePage();
await waitForNetworkIdle(page, { idleMs: 50, timeoutMs: 1000 });
});
it("tracks requestfailed alongside requestfinished", {
timeout: 5000,
}, async () => {
const { page, emit } = makePage();
const promise = waitForNetworkIdle(page, {
idleMs: 50,
timeoutMs: 1000,
});
emit("request");
setTimeout(() => emit("requestfailed"), 10);
await promise;
});
it("uses default options when none supplied", {
timeout: 5000,
}, async () => {
const { page } = makePage();
// Default idleMs=500; wait long enough for it to resolve naturally.
await waitForNetworkIdle(page);
});
it("guards against negative inFlight on stray settle events", {
timeout: 5000,
}, async () => {
const { page, emit } = makePage();
// Emit settle without a preceding request — Math.max(0, -1) path.
emit("requestfinished");
await waitForNetworkIdle(page, { idleMs: 50, timeoutMs: 1000 });
});
it("keeps lastIdleAt unchanged when settle leaves inFlight > 0", {
timeout: 5000,
}, async () => {
const { page, emit } = makePage();
const promise = waitForNetworkIdle(page, {
idleMs: 50,
timeoutMs: 1000,
});
// Two requests in flight; one settles but the other remains.
emit("request");
emit("request");
setTimeout(() => emit("requestfinished"), 10); // inFlight→1, not 0
setTimeout(() => emit("requestfinished"), 20); // inFlight→0
await promise;
});
it("throws WaitTimeoutError when network never settles", {
timeout: 5000,
}, async () => {
const { page, emit } = makePage();
// Continuously fire requests so inFlight never reaches 0
// for idleMs.
const interval = setInterval(() => emit("request"), 10);
try {
await waitForNetworkIdle(page, {
idleMs: 100,
timeoutMs: 200,
});
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(WaitTimeoutError);
expect((err as WaitTimeoutError).code).toBe(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT
);
} finally {
clearInterval(interval);
}
});
});

View File

@@ -0,0 +1,53 @@
/**
* Wait for the page's in-flight request count to be zero for `idleMs`
* consecutive milliseconds. Throws `WaitTimeoutError` if the network
* never settles within `timeoutMs`.
*/
import type { Page } from "@playwright/test";
import { TESTING_E2E_ERROR_CODES } from "../errors/codes.js";
import { WaitTimeoutError } from "../errors/testing-e2e-error.js";
export interface WaitForNetworkIdleOptions {
readonly timeoutMs?: number;
readonly idleMs?: number;
}
export async function waitForNetworkIdle(
page: Page,
options: WaitForNetworkIdleOptions = {}
): Promise<void> {
const timeoutMs = options.timeoutMs ?? 30_000;
const idleMs = options.idleMs ?? 500;
let inFlight = 0;
let lastIdleAt = Date.now();
const onRequest = (): void => {
inFlight++;
lastIdleAt = Date.now();
};
const onSettle = (): void => {
inFlight = Math.max(0, inFlight - 1);
if (inFlight === 0) lastIdleAt = Date.now();
};
page.on("request", onRequest);
page.on("requestfinished", onSettle);
page.on("requestfailed", onSettle);
const start = Date.now();
try {
while (true) {
if (inFlight === 0 && Date.now() - lastIdleAt >= idleMs) return;
if (Date.now() - start >= timeoutMs) {
throw new WaitTimeoutError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
`waitForNetworkIdle: network did not settle within ${timeoutMs}ms (inFlight=${inFlight})`,
{ context: { timeoutMs, idleMs, inFlight } }
);
}
await new Promise<void>((r) => setTimeout(r, 50));
}
} finally {
page.off("request", onRequest);
page.off("requestfinished", onSettle);
page.off("requestfailed", onSettle);
}
}

View File

@@ -0,0 +1,110 @@
/**
* `@suaveplan/testing-e2e` — Playwright E2E utilities.
*
* Re-exports Playwright primitives, plus a `createPlaywrightConfig`
* factory, `PageObject` base class, helpers, fixtures, and a local
* `TestingE2eError` hierarchy.
*
* Layer 0 — no `@suaveplan/*` runtime dependencies.
*/
// Playwright re-exports
export {
type APIRequestContext,
type Browser,
type BrowserContext,
expect,
type Locator,
type Page,
request,
type TestInfo,
test,
} from "@playwright/test";
// Cloakserve CDP multiplexer
export {
buildCloakServeUrl,
type CloakServeConnectOptions,
type CloakServeFingerprint,
connectCloakServe,
} from "./cloakserve/index.js";
// Config
export {
type CreatePlaywrightConfigOptions,
createPlaywrightConfig,
} from "./config/create-playwright-config.js";
// Console monitor
export { attachConsoleMonitor } from "./console-monitor/console-monitor.js";
export type {
ConsoleMonitor,
ConsoleMonitorOptions,
} from "./console-monitor/console-monitor.types.js";
// Errors
export {
AbortedError,
CloakLaunchError,
ConfigError,
classifyCloakError,
FixtureError,
NavigationError,
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
TestingE2eError,
type TestingE2eErrorCode,
type TestingE2eErrorOptions,
WaitTimeoutError,
wrapCloakError,
} from "./errors/index.js";
// Extension fixture
export { launchWithExtension } from "./extension-fixture/launch-with-extension.js";
export type {
CloakLaunchPersistentContext,
ExtensionFixtureOptions,
ExtensionFixtureResult,
} from "./extension-fixture/launch-with-extension.types.js";
// Fixtures
export {
type CloakLaunchFn,
type CloakOptions,
extendedTest,
launchBrowserWithCloak,
} from "./fixtures/extended-test.js";
// Helpers
export {
type DismissDialogOptions,
dismissDialog,
type GeolocationCoords,
mockClipboard,
mockGeolocation,
screenshotOnFailure,
type WaitForNetworkIdleOptions,
waitForNetworkIdle,
} from "./helpers/index.js";
// Multi-session
export {
createMultiSession,
simulateServerFailure,
} from "./multi-session/create-multi-session.js";
export type {
MultiSessionOptions,
MultiSessionResult,
} from "./multi-session/create-multi-session.types.js";
// Network monitor
export { attachNetworkMonitor } from "./network-monitor/network-monitor.js";
export type {
NetworkFailure,
NetworkMonitor,
} from "./network-monitor/network-monitor.types.js";
// Observability
export { instrumented } from "./observability/index.js";
// Page object
export {
PageObject,
type WaitForReadyOptions,
} from "./page-objects/page-object.js";
// Stealth presets
export {
STEALTH_DEFAULT_CLOAK,
STEALTH_HEADED_CLOAK,
stealthMax,
stealthMaxHeadless,
} from "./presets/index.js";

View File

@@ -0,0 +1,109 @@
# create-multi-session
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createMultiSession` opens multiple isolated Playwright browser contexts against the same base URL, with each context navigated to a role-specific URL variant. It provides the foundation for multi-participant E2E tests covering WebRTC room scenarios, WebSocket-based collaboration, and BroadcastChannel coordination where multiple independent browser sessions must operate simultaneously within a single test.
`simulateServerFailure` sends an HTTP POST to a Hono testbed server's `/api/test-control/disable-route` endpoint mid-test to simulate backend route failures. Together these two functions make it possible to write deterministic chaos tests without modifying application source code or using network interception layers that are difficult to reset reliably.
## Features
- Opens N isolated browser contexts sequentially using `browser.newContext()`
- Each context gets its own page navigated to `${baseUrl}?role=${role}` or `${baseUrl}&role=${role}` depending on whether a query string is already present
- Roles are assigned positionally; out-of-bounds indices fall back to the numeric index as a string
- Returns a `teardown()` function that closes all contexts in parallel via `Promise.all`
- `simulateServerFailure` accepts an injectable `fetchFn` for full unit testability without network
- `simulateServerFailure` throws a descriptive error when the server does not return 204
## Usage: Basic Example
```typescript
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
import { chromium } from "@playwright/test";
const browser = await chromium.launch();
const session = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/room/abc123",
roles: ["host", "guest"],
});
// session.pages[0] is navigated to /room/abc123?role=host
// session.pages[1] is navigated to /room/abc123?role=guest
await session.teardown();
await browser.close();
```
## Usage: Advanced Example
```typescript
import { createMultiSession, simulateServerFailure } from "@suaveplan/testing-e2e/multi-session";
test("reconnects after server route is disabled mid-session", async ({ browser }) => {
const session = await createMultiSession({
browser,
count: 3,
baseUrl: "http://localhost:3200/network-webrtc?session=test-42",
roles: ["host", "peer", "peer"],
});
// Establish initial state on all pages
for (const page of session.pages) {
await page.waitForSelector("[data-testid=connected]");
}
// Disable the signalling route mid-test
await simulateServerFailure("http://localhost:3200", "/api/webrtc/signal");
// Verify reconnection UI appears
await session.pages[1]!.waitForSelector("[data-testid=reconnecting]");
await session.teardown();
});
```
## API Reference
### `createMultiSession(options: MultiSessionOptions): Promise<MultiSessionResult>`
Creates multiple isolated browser sessions pointing to role-scoped URLs.
**Parameters:**
- `options.browser` — Playwright `Browser` instance (from fixture or `chromium.launch()`)
- `options.count` — number of contexts/pages to open
- `options.baseUrl` — base URL to navigate each page to; `?role=<value>` or `&role=<value>` is appended
- `options.roles` — array of role strings, positionally aligned with page index
**Returns:** `MultiSessionResult`
- `.pages` — array of `Page` instances, one per context, in creation order
- `.contexts` — array of `BrowserContext` instances in creation order
- `.teardown()` — async function that closes all contexts in parallel
**Throws:** propagates any error thrown by `browser.newContext()`, `context.newPage()`, or `page.goto()`.
### `simulateServerFailure(apiBaseUrl: string, route: string, fetchFn?: typeof fetch): Promise<void>`
Sends a POST request to `${apiBaseUrl}/api/test-control/disable-route` with `{ route }` as the JSON body.
**Parameters:**
- `apiBaseUrl` — base URL of the running Hono testbed server
- `route` — the route path to disable (e.g. `"/api/storage"`)
- `fetchFn` — injectable fetch implementation (defaults to global `fetch`)
**Throws:** `Error` with message `simulateServerFailure: expected 204, got <status>` when the server responds with any status other than 204.
## Implementation Notes
The `?` vs `&` separator detection uses `baseUrl.includes("?")` — a deliberate simplification that works for all well-formed URLs used in testbed scenarios. Full URL parsing via the `URL` constructor would be more correct but adds latency and is unnecessary given that baseUrls in this context are always known-good strings constructed by test code.
Contexts are opened sequentially (not in parallel) to avoid race conditions on shared browser resources during test setup. Teardown uses `Promise.all` because context shutdown order does not matter and parallel close is faster.
`simulateServerFailure` is intentionally a thin HTTP shim — the testbed server owns the state machine for disabling routes. This approach keeps the test helper free of implementation details about how route disabling works server-side.

View File

@@ -0,0 +1,193 @@
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
function makeFakePage(gotoFn = vi.fn(async () => null)): Page {
return { goto: gotoFn } as unknown as Page;
}
function makeFakeContext(page: Page): BrowserContext {
return {
newPage: vi.fn(async () => page),
close: vi.fn(async () => undefined),
} as unknown as BrowserContext;
}
function makeFakeBrowser(contexts: BrowserContext[]): Browser {
let i = 0;
return {
newContext: vi.fn(async () => contexts[i++]!),
} as unknown as Browser;
}
function makeFetch(status: number) {
return vi.fn(async () => ({ status })) as unknown as typeof fetch;
}
describe("createMultiSession", () => {
it("creates count contexts and pages, navigates each to baseUrl?role=<role>", {
timeout: 30000,
}, async () => {
const { createMultiSession } = await import(
"./create-multi-session.js"
);
const pages = [makeFakePage(), makeFakePage(), makeFakePage()];
const contexts = pages.map(makeFakeContext);
const browser = makeFakeBrowser(contexts);
const result = await createMultiSession({
browser,
count: 3,
baseUrl: "http://localhost:3200/network-webrtc?session=abc",
roles: ["host", "peer", "peer"],
});
expect(result.pages).toHaveLength(3);
expect(result.pages[0]).toBe(pages[0]);
expect(pages[0]!.goto as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"http://localhost:3200/network-webrtc?session=abc&role=host"
);
expect(pages[1]!.goto as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"http://localhost:3200/network-webrtc?session=abc&role=peer"
);
});
it("teardown closes all contexts", { timeout: 30000 }, async () => {
const { createMultiSession } = await import(
"./create-multi-session.js"
);
const pages = [makeFakePage(), makeFakePage()];
const contexts = pages.map(makeFakeContext);
const browser = makeFakeBrowser(contexts);
const result = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/test",
roles: ["a", "b"],
});
await result.teardown();
expect(contexts[0]!.close).toHaveBeenCalledOnce();
expect(contexts[1]!.close).toHaveBeenCalledOnce();
});
it("appends role param with & when baseUrl has query string", {
timeout: 30000,
}, async () => {
const { createMultiSession } = await import(
"./create-multi-session.js"
);
const page = makeFakePage();
const context = makeFakeContext(page);
const browser = makeFakeBrowser([context]);
await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/room?session=xyz",
roles: ["viewer"],
});
expect(page.goto as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"http://localhost:3200/room?session=xyz&role=viewer"
);
});
it("appends role param with ? when baseUrl has no query string", {
timeout: 30000,
}, async () => {
const { createMultiSession } = await import(
"./create-multi-session.js"
);
const page = makeFakePage();
const context = makeFakeContext(page);
const browser = makeFakeBrowser([context]);
await createMultiSession({
browser,
count: 1,
baseUrl: "http://localhost:3200/room",
roles: ["viewer"],
});
expect(page.goto as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"http://localhost:3200/room?role=viewer"
);
});
it("falls back to index string when roles array is shorter than count", {
timeout: 30000,
}, async () => {
const { createMultiSession } = await import(
"./create-multi-session.js"
);
const pages = [makeFakePage(), makeFakePage()];
const contexts = pages.map(makeFakeContext);
const browser = makeFakeBrowser(contexts);
await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/room",
roles: ["host"],
});
// roles[1] is undefined, should fall back to "1"
expect(pages[1]!.goto as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
"http://localhost:3200/room?role=1"
);
});
});
describe("simulateServerFailure", () => {
it("sends POST to /api/test-control/disable-route and accepts 204", {
timeout: 30000,
}, async () => {
const { simulateServerFailure } = await import(
"./create-multi-session.js"
);
const fetchFn = makeFetch(204);
await simulateServerFailure(
"http://localhost:3200",
"/api/storage",
fetchFn
);
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:3200/api/test-control/disable-route",
expect.objectContaining({ method: "POST" })
);
});
it("throws when response status is not 204", {
timeout: 30000,
}, async () => {
const { simulateServerFailure } = await import(
"./create-multi-session.js"
);
const fetchFn = makeFetch(500);
await expect(
simulateServerFailure(
"http://localhost:3200",
"/api/storage",
fetchFn
)
).rejects.toThrow("simulateServerFailure: expected 204, got 500");
});
it("uses global fetch when no fetchFn provided", {
timeout: 30000,
}, async () => {
const { simulateServerFailure } = await import(
"./create-multi-session.js"
);
const fakeFetch = vi.fn(async () => ({ status: 204 }));
const originalFetch = globalThis.fetch;
globalThis.fetch = fakeFetch as unknown as typeof fetch;
try {
await simulateServerFailure(
"http://localhost:3200",
"/api/storage"
);
expect(fakeFetch).toHaveBeenCalledWith(
"http://localhost:3200/api/test-control/disable-route",
expect.objectContaining({ method: "POST" })
);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,47 @@
import type { BrowserContext, Page } from "@playwright/test";
import type {
MultiSessionOptions,
MultiSessionResult,
} from "./create-multi-session.types.js";
export async function createMultiSession(
options: MultiSessionOptions
): Promise<MultiSessionResult> {
const { browser, count, baseUrl, roles } = options;
const separator = baseUrl.includes("?") ? "&" : "?";
const contexts: BrowserContext[] = [];
const pages: Page[] = [];
for (let i = 0; i < count; i++) {
const context = await browser.newContext();
const page = await context.newPage();
const role = roles[i] ?? String(i);
await page.goto(`${baseUrl}${separator}role=${role}`);
contexts.push(context);
pages.push(page);
}
const teardown = async (): Promise<void> => {
await Promise.all(contexts.map((ctx) => ctx.close()));
};
return { pages, contexts, teardown };
}
export async function simulateServerFailure(
apiBaseUrl: string,
route: string,
fetchFn: typeof fetch = fetch
): Promise<void> {
const url = `${apiBaseUrl}/api/test-control/disable-route`;
const response = await fetchFn(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ route }),
});
if (response.status !== 204) {
throw new Error(
`simulateServerFailure: expected 204, got ${response.status}`
);
}
}

View File

@@ -0,0 +1,14 @@
import type { Browser, BrowserContext, Page } from "@playwright/test";
export interface MultiSessionOptions {
browser: Browser;
count: number;
baseUrl: string;
roles: string[];
}
export interface MultiSessionResult {
pages: Page[];
contexts: BrowserContext[];
teardown: () => Promise<void>;
}

View File

@@ -0,0 +1,8 @@
export {
createMultiSession,
simulateServerFailure,
} from "./create-multi-session.js";
export type {
MultiSessionOptions,
MultiSessionResult,
} from "./create-multi-session.types.js";

View File

@@ -0,0 +1,5 @@
export { attachNetworkMonitor } from "./network-monitor.js";
export type {
NetworkFailure,
NetworkMonitor,
} from "./network-monitor.types.js";

View File

@@ -0,0 +1,104 @@
# network-monitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachNetworkMonitor` intercepts all network activity on a Playwright `Page` and records every HTTP 4xx/5xx response and every network-level request failure (connection refused, DNS failure, aborted). Its `assertNoFailures()` method fails the test if any failures were captured. Used as a standard fixture in every E2E spec to catch unexpected API errors and missing assets automatically, without requiring individual assertions for each network call. This creates a zero-escape-hatch guarantee: tests that make unintended requests to broken endpoints are caught immediately rather than producing silent wrong-state failures.
The monitor is intentionally passive — it observes without intercepting or blocking requests. Unlike Playwright route mocking, it lets the full application network stack run unmodified.
## Features
- Registers `page.on("response")` and `page.on("requestfailed")` listeners
- Records any response with HTTP status >= 400 as a `"status"` failure including the URL and status code
- Records `requestfailed` events (connection errors, aborts, DNS failures) as `"failed"` failures including the error text from `request.failure()`
- Passes 2xx and 3xx responses through without recording
- Handles `request.failure()` returning `null` gracefully — records the failure with `errorText: undefined`
- `assertNoFailures()` throws with a formatted multi-line list of all recorded failures
- Exposes the raw `failures` array for test-specific inspection
## Usage: Basic Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { test } from "@playwright/test";
test("login page makes no failed requests", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("http://localhost:3200/login");
await page.waitForLoadState("networkidle");
monitor.assertNoFailures();
});
```
## Usage: Advanced Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test } from "@playwright/test";
test("dashboard loads all assets and API calls succeed", async ({ page }) => {
const network = attachNetworkMonitor(page);
const console = attachConsoleMonitor(page, { failOn: ["error"] });
await page.goto("http://localhost:3200/dashboard");
await page.waitForSelector("[data-testid=dashboard-ready]");
// Inspect specific failures before blanket assertion
const apiFailures = network.failures.filter((f) =>
f.url.includes("/api/")
);
if (apiFailures.length > 0) {
console.log("API failures:", apiFailures);
}
network.assertNoFailures();
console.assertClean();
});
```
## API Reference
### `attachNetworkMonitor(page: Page): NetworkMonitor`
Attaches response and request-failed listeners to the given Playwright page.
**Parameters:**
- `page` — Playwright `Page` instance to monitor
**Returns:** `NetworkMonitor`
- `.failures` — read-only array of `NetworkFailure` objects collected since the monitor was attached
- `.assertNoFailures()` — throws `Error` listing all recorded failures if the array is non-empty
### `NetworkFailure` interface
```typescript
interface NetworkFailure {
url: string;
type: "status" | "failed";
status?: number; // set for type === "status"
errorText?: string; // set for type === "failed" when failure() is non-null
}
```
### `NetworkMonitor` interface
```typescript
interface NetworkMonitor {
readonly failures: NetworkFailure[];
assertNoFailures(): void;
}
```
## Implementation Notes
The status threshold of 400 is inclusive: 400 Bad Request through all 5xx server errors are recorded. 301/302 redirects and 304 Not Modified responses are not recorded — these are normal browser behaviour during page load. 401 and 403 are deliberately included because they almost always indicate a test environment misconfiguration (wrong auth token, missing seed data).
`request.failure()` returns `null` for requests that were cancelled by the browser's own navigation logic rather than failing at the network layer. The monitor records these as failures with `errorText: undefined` to preserve the URL for debugging, since they may indicate leaked requests from prior test steps.
The `failures` getter returns the live internal array. Like `console-monitor`, this avoids copy overhead and the live reference is safe because test assertions run synchronously after the monitored page actions complete.

View File

@@ -0,0 +1,179 @@
import type { Page, Request, Response } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
type EventCallback = (arg: Request | Response) => void;
function makeFakePage() {
const listeners = new Map<string, EventCallback[]>();
const page = {
on: vi.fn((event: string, cb: EventCallback) => {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(cb);
}),
} as unknown as Page;
function emit(event: string, arg: Request | Response) {
for (const cb of listeners.get(event) ?? []) cb(arg);
}
return { page, emit };
}
function fakeResponse(url: string, status: number): Response {
return {
url: () => url,
status: () => status,
request: () => ({ url: () => url }) as unknown as Request,
} as unknown as Response;
}
function fakeFailedRequest(url: string, errorText: string): Request {
return {
url: () => url,
failure: () => ({ errorText }),
} as unknown as Request;
}
describe("attachNetworkMonitor", () => {
it("registers response and requestfailed listeners", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page } = makeFakePage();
attachNetworkMonitor(page);
const onCalls = (page.on as ReturnType<typeof vi.fn>).mock.calls.map(
(c: unknown[]) => c[0]
);
expect(onCalls).toContain("response");
expect(onCalls).toContain("requestfailed");
});
it("records 4xx responses as failures", { timeout: 5000 }, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"response",
fakeResponse("http://localhost:3200/api/missing", 404)
);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.status).toBe(404);
expect(monitor.failures[0]!.type).toBe("status");
});
it("records 5xx responses as failures", { timeout: 5000 }, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/api/boom", 500));
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.status).toBe(500);
});
it("does not record 2xx responses as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/api/ok", 200));
expect(monitor.failures).toHaveLength(0);
});
it("does not record 3xx responses as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit("response", fakeResponse("http://localhost:3200/redirect", 302));
expect(monitor.failures).toHaveLength(0);
});
it("records requestfailed events as failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"requestfailed",
fakeFailedRequest(
"http://localhost:3200/api/x",
"net::ERR_CONNECTION_REFUSED"
)
);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.type).toBe("failed");
expect(monitor.failures[0]!.errorText).toBe(
"net::ERR_CONNECTION_REFUSED"
);
});
it("assertNoFailures does not throw when no failures", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page } = makeFakePage();
const monitor = attachNetworkMonitor(page);
expect(() => monitor.assertNoFailures()).not.toThrow();
});
it("assertNoFailures throws when failures exist", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"response",
fakeResponse("http://localhost:3200/api/missing", 404)
);
expect(() => monitor.assertNoFailures()).toThrow("404");
});
it("records requestfailed with no failure() object gracefully", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
const req = {
url: () => "http://localhost:3200/img.png",
failure: () => null,
} as unknown as Request;
emit("requestfailed", req);
expect(monitor.failures).toHaveLength(1);
expect(monitor.failures[0]!.errorText).toBeUndefined();
});
it("assertNoFailures includes errorText in message for failed requests", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
emit(
"requestfailed",
fakeFailedRequest(
"http://localhost:3200/api/x",
"net::ERR_CONNECTION_REFUSED"
)
);
expect(() => monitor.assertNoFailures()).toThrow(
"net::ERR_CONNECTION_REFUSED"
);
});
it("assertNoFailures includes FAILED label without errorText when failure is null", {
timeout: 5000,
}, async () => {
const { attachNetworkMonitor } = await import("./network-monitor.js");
const { page, emit } = makeFakePage();
const monitor = attachNetworkMonitor(page);
const req = {
url: () => "http://localhost:3200/img.png",
failure: () => null,
} as unknown as Request;
emit("requestfailed", req);
expect(() => monitor.assertNoFailures()).toThrow("[FAILED]");
});
});

View File

@@ -0,0 +1,44 @@
import type { Page, Request, Response } from "@playwright/test";
import type {
NetworkFailure,
NetworkMonitor,
} from "./network-monitor.types.js";
export function attachNetworkMonitor(page: Page): NetworkMonitor {
const failures: NetworkFailure[] = [];
page.on("response", (response: Response) => {
const status = response.status();
if (status >= 400) {
failures.push({ url: response.url(), type: "status", status });
}
});
page.on("requestfailed", (request: Request) => {
const failure = request.failure();
const entry: NetworkFailure = { url: request.url(), type: "failed" };
if (failure?.errorText !== undefined) {
entry.errorText = failure.errorText;
}
failures.push(entry);
});
return {
get failures() {
return failures;
},
assertNoFailures() {
if (failures.length === 0) return;
const lines = failures
.map((f) =>
f.type === "status"
? ` [${f.status}] ${f.url}`
: ` [FAILED] ${f.url}${f.errorText ? ` -- ${f.errorText}` : ""}`
)
.join("\n");
throw new Error(
`NetworkMonitor: unexpected network failures:\n${lines}`
);
},
};
}

View File

@@ -0,0 +1,11 @@
export interface NetworkFailure {
url: string;
type: "status" | "failed";
status?: number;
errorText?: string;
}
export interface NetworkMonitor {
readonly failures: NetworkFailure[];
assertNoFailures(): void;
}

View File

@@ -0,0 +1 @@
export { instrumented } from "./instrumented.js";

View File

@@ -0,0 +1,66 @@
# observability/instrumented
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instrumented` is the gated OpenTelemetry wrapper for `@suaveplan/testing-e2e` helpers and fixtures. When `TESTING_E2E_OTEL_ENABLED` is set, wrapped calls emit counters for invocations, histograms for durations, and span events for failures — giving a Playwright test run the same structured telemetry as a production service. When the flag is off, `instrumented` returns the original function reference unchanged so test-run cost is identical to the unwrapped case.
## Features
- Gated at module import via `TESTING_E2E_OTEL_ENABLED` — no per-call env reads
- Uses `@opentelemetry/api` only (no SDK) — consumers wire exporters at bootstrap
- Preserves signature and return type of the wrapped function
- Handles sync and async fn forms, including rejection
- Zero overhead when disabled (identity pass-through)
## Usage: Basic Example
```ts
import { instrumented } from "@suaveplan/testing-e2e/observability";
const navigate = instrumented("testing_e2e.navigate", async (page: Page, url: string) => {
await page.goto(url);
});
```
## Usage: Advanced Example
```ts
import { instrumented } from "@suaveplan/testing-e2e/observability";
class DashboardPage {
public gotoInstrumented = instrumented(
"testing_e2e.page.dashboard.goto",
async (page: Page) => {
await page.goto("/dashboard");
await page.waitForLoadState("domcontentloaded");
}
);
public filterInstrumented = instrumented(
"testing_e2e.page.dashboard.filter",
async (page: Page, query: string) => {
await page.fill('input[name="q"]', query);
await page.press('input[name="q"]', "Enter");
}
);
}
```
## API Reference
### `instrumented<TFn>(name, fn): TFn`
- `name` — metric name suffix; the full name becomes `testing_e2e.<name>.count` / `duration_ms` / `errors`
- `fn` — any function to wrap
- Returns `TFn` with the same signature; instrumented only when the env flag is set
### Environment gating
`TESTING_E2E_OTEL_ENABLED` must equal `"1"` or `"true"` at module-import time.
## Implementation Notes
The env check runs once at module import — flipping the variable mid-run has no effect, which avoids per-call reads in the hot path. With instrumentation enabled but no OTel SDK registered, `@opentelemetry/api` returns no-op tracers and meters, so the overhead is a single no-op counter increment. The disabled branch returns the original function reference, so V8 inlines it as if the wrapper was never there.

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { instrumented } from "./instrumented.js";
const ORIGINAL_GATE = process.env["TESTING_E2E_OTEL_ENABLED"];
afterEach(() => {
if (ORIGINAL_GATE === undefined) {
delete process.env["TESTING_E2E_OTEL_ENABLED"];
} else {
process.env["TESTING_E2E_OTEL_ENABLED"] = ORIGINAL_GATE;
}
vi.unstubAllGlobals();
});
describe("instrumented — gate disabled (default)", () => {
beforeEach(() => {
delete process.env["TESTING_E2E_OTEL_ENABLED"];
});
it("returns the function unchanged when gate is off", {
timeout: 5000,
}, () => {
const inner = (x: number): number => x + 1;
const wrapped = instrumented("test.add", inner);
expect(wrapped).toBe(inner);
expect(wrapped(1)).toBe(2);
});
});
describe("instrumented — gate enabled", () => {
beforeEach(() => {
process.env["TESTING_E2E_OTEL_ENABLED"] = "1";
});
it("wraps the function and preserves return value", {
timeout: 5000,
}, () => {
const wrapped = instrumented("test.add2", (x: number) => x * 2);
expect(wrapped(3)).toBe(6);
});
it("rethrows errors", { timeout: 5000 }, () => {
const wrapped = instrumented("test.throw", () => {
throw new Error("boom");
});
expect(() => wrapped()).toThrow("boom");
});
it("rethrows non-Error throws", { timeout: 5000 }, () => {
const wrapped = instrumented("test.throw-string", () => {
throw "raw";
});
expect(() => wrapped()).toThrow("raw");
});
it("caches counters/histograms when called twice", {
timeout: 5000,
}, () => {
const a = instrumented("test.cache", (x: number) => x);
const b = instrumented("test.cache", (x: number) => x * 2);
expect(a(1)).toBe(1);
expect(b(1)).toBe(2);
});
it("falls back to Date.now when performance is missing", {
timeout: 5000,
}, () => {
vi.stubGlobal("performance", undefined as unknown as Performance);
const wrapped = instrumented(
"edge.no-performance",
(x: number) => x + 1
);
expect(wrapped(1)).toBe(2);
});
it("ends span only after async fn settles (resolved)", {
timeout: 5000,
}, async () => {
const wrapped = instrumented(
"test.async-resolve",
async (x: number) => {
await new Promise((r) => setTimeout(r, 10));
return x + 1;
}
);
const start = Date.now();
const result = await wrapped(2);
const elapsed = Date.now() - start;
expect(result).toBe(3);
expect(elapsed).toBeGreaterThanOrEqual(10);
});
it("ends span only after async fn settles (rejected)", {
timeout: 5000,
}, async () => {
const wrapped = instrumented("test.async-reject", async () => {
await new Promise((r) => setTimeout(r, 5));
throw new Error("async-boom");
});
await expect(wrapped()).rejects.toThrow("async-boom");
});
it("ends span only after async fn settles (rejected with non-Error)", {
timeout: 5000,
}, async () => {
const wrapped = instrumented("test.async-reject-string", async () => {
await new Promise((r) => setTimeout(r, 1));
return Promise.reject("raw-async");
});
await expect(wrapped()).rejects.toBe("raw-async");
});
});

View File

@@ -0,0 +1,101 @@
/**
* OTEL instrumentation gated behind `process.env.TESTING_E2E_OTEL_ENABLED === "1"`.
* No-op otherwise. Mirrors the testing-dom pattern.
*/
import { metrics, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/testing-e2e";
function isEnabled(): boolean {
return (
typeof process !== "undefined" &&
process.env["TESTING_E2E_OTEL_ENABLED"] === "1"
);
}
const tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(TRACER_NAME);
const counters = new Map<string, ReturnType<typeof meter.createCounter>>();
const histograms = new Map<string, ReturnType<typeof meter.createHistogram>>();
function getCounter(name: string): ReturnType<typeof meter.createCounter> {
let counter = counters.get(name);
if (!counter) {
counter = meter.createCounter(name);
counters.set(name, counter);
}
return counter;
}
function getHistogram(name: string): ReturnType<typeof meter.createHistogram> {
let histogram = histograms.get(name);
if (!histogram) {
histogram = meter.createHistogram(name, { unit: "ms" });
histograms.set(name, histogram);
}
return histogram;
}
export function instrumented<TArgs extends unknown[], TReturn>(
name: string,
fn: (...args: TArgs) => TReturn
): (...args: TArgs) => TReturn {
if (!isEnabled()) return fn;
const counter = getCounter(`testing_e2e.${name}.count`);
const histogram = getHistogram(`testing_e2e.${name}.duration_ms`);
return function instrumentedWrapper(...args: TArgs): TReturn {
const start = now();
counter.add(1);
return tracer.startActiveSpan(`testing_e2e.${name}`, (span) => {
try {
const result = fn(...args);
if (isPromiseLike(result)) {
return result.then(
(value) => {
span.end();
histogram.record(now() - start);
return value;
},
(err: unknown) => {
span.recordException(
err instanceof Error
? err
: new Error(String(err))
);
span.end();
histogram.record(now() - start);
throw err;
}
) as TReturn;
}
span.end();
histogram.record(now() - start);
return result;
} catch (err) {
span.recordException(
err instanceof Error ? err : new Error(String(err))
);
span.end();
histogram.record(now() - start);
throw err;
}
});
};
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
function now(): number {
return typeof performance !== "undefined" &&
typeof performance.now === "function"
? performance.now()
: Date.now();
}

View File

@@ -0,0 +1 @@
export { PageObject, type WaitForReadyOptions } from "./page-object";

View File

@@ -0,0 +1,87 @@
# page-object
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PageObject` is the abstract base class for Playwright Page Object Model implementations. It wires every subclass with a consistent `navigate` / `waitForReady` / `findByRole` / `findByLabel` / `findByText` surface so spec files stay declarative. Subclasses override the `path` and `waitForReady` specifics while inheriting the shared query conveniences.
## Features
- Abstract `path: string` — subclass declares the route
- `navigate(options?)` — delegates to `page.goto` with the configured path
- `waitForReady(options?)` — subclass overrides to assert page-specific readiness
- Query helpers: `findByRole`, `findByLabel`, `findByText` — thin wrappers over `page.getBy*`
- `exactOptionalPropertyTypes`-safe: `timeout` is forwarded only when set
## Usage: Basic Example
```ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
class LoginPage extends PageObject {
public readonly path = "/login";
public async waitForReady(): Promise<void> {
await this.findByRole("heading", { name: /sign in/i });
}
}
// spec:
const login = new LoginPage(page);
await login.navigate();
await login.waitForReady();
```
## Usage: Advanced Example
```ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
import { expect } from "@playwright/test";
class CheckoutPage extends PageObject {
public readonly path = "/checkout";
public async waitForReady({ timeoutMs }: { timeoutMs?: number } = {}): Promise<void> {
const headingFinder = this.findByRole("heading", { name: /checkout/i });
await expect(headingFinder).toBeVisible({
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
});
}
public async enterCardNumber(number: string): Promise<void> {
await this.findByLabel("Card number").fill(number);
}
public async submit(): Promise<void> {
await this.findByRole("button", { name: /pay now/i }).click();
}
}
// spec:
const checkout = new CheckoutPage(page);
await checkout.navigate({ timeoutMs: 10_000 });
await checkout.waitForReady();
await checkout.enterCardNumber("4242 4242 4242 4242");
await checkout.submit();
```
## API Reference
### `abstract class PageObject`
- `abstract readonly path: string`
- `constructor(page: Page)`
- `navigate(options?: { timeoutMs?: number }): Promise<void>`
- `waitForReady(options?: { timeoutMs?: number }): Promise<void>` — subclass override
- `findByRole(role, options?)` → `Locator`
- `findByLabel(label)` → `Locator`
- `findByText(text)` → `Locator`
## Implementation Notes
`navigate` calls `page.waitForLoadState("domcontentloaded", waitOptions)` where `waitOptions` is `undefined` when no timeout is supplied and `{ timeout }` when one is — this conditional construction is required by `exactOptionalPropertyTypes` which forbids passing `{ timeout: undefined }`. The class deliberately stays small; subclass-specific assertions belong on the subclass, not baked into a common helper that tries to anticipate every scenario.
### Base Class
`PageObject` is the minimal base class for Playwright page-object-model helpers. It carries a `page: Page` reference, a `baseUrl: string`, and exposes `goto(path?)` / `expectVisible(locator)` / `expectUrlMatches(regex)` helpers that every subclass uses. Subclasses narrow the locator surface to the page's specific components and keep tests focused on flows rather than selectors.

View File

@@ -0,0 +1,104 @@
import type { Locator, Page } from "@playwright/test";
import { describe, expect, it, vi } from "vitest";
import { TESTING_E2E_ERROR_CODES } from "../errors/codes.js";
import { AbortedError, NavigationError } from "../errors/testing-e2e-error.js";
import { PageObject } from "./page-object.js";
class HomePage extends PageObject {
public readonly path = "/home";
}
function makeMockPage(overrides: Partial<Page> = {}): Page {
const locator = { __locator: true } as unknown as Locator;
const base = {
goto: vi.fn(async (_path: string) => null),
waitForLoadState: vi.fn(async () => undefined),
getByRole: vi.fn(() => locator),
getByLabel: vi.fn(() => locator),
getByText: vi.fn(() => locator),
};
return { ...base, ...overrides } as unknown as Page;
}
describe("PageObject — happy path", () => {
it("navigate calls page.goto with the configured path", {
timeout: 5000,
}, async () => {
const page = makeMockPage();
const home = new HomePage(page);
await home.navigate();
expect(page.goto).toHaveBeenCalledWith("/home");
});
it("waitForReady delegates to page.waitForLoadState", {
timeout: 5000,
}, async () => {
const page = makeMockPage();
const home = new HomePage(page);
await home.waitForReady();
expect(page.waitForLoadState).toHaveBeenCalledWith(
"domcontentloaded",
undefined
);
});
it("waitForReady passes timeout when provided", {
timeout: 5000,
}, async () => {
const page = makeMockPage();
const home = new HomePage(page);
await home.waitForReady({ timeoutMs: 10_000 });
expect(page.waitForLoadState).toHaveBeenCalledWith("domcontentloaded", {
timeout: 10_000,
});
});
it("findByRole / findByLabel / findByText delegate", {
timeout: 5000,
}, () => {
const page = makeMockPage();
const home = new HomePage(page);
home.findByRole("button");
home.findByLabel("Username");
home.findByText("hello");
expect(page.getByRole).toHaveBeenCalledWith("button", undefined);
expect(page.getByLabel).toHaveBeenCalledWith("Username", undefined);
expect(page.getByText).toHaveBeenCalledWith("hello", undefined);
});
});
describe("PageObject — errors", () => {
it("navigate wraps page.goto failure as NavigationError", {
timeout: 5000,
}, async () => {
const page = makeMockPage({
goto: vi.fn(async () => {
throw new Error("boom");
}) as unknown as Page["goto"],
});
const home = new HomePage(page);
await expect(home.navigate()).rejects.toBeInstanceOf(NavigationError);
try {
await home.navigate();
} catch (err) {
expect((err as NavigationError).code).toBe(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED
);
expect((err as NavigationError).context).toMatchObject({
path: "/home",
});
}
});
it("waitForReady throws AbortedError on pre-aborted signal", {
timeout: 5000,
}, async () => {
const page = makeMockPage();
const home = new HomePage(page);
const ctrl = new AbortController();
ctrl.abort(new Error("nope"));
await expect(
home.waitForReady({ signal: ctrl.signal })
).rejects.toBeInstanceOf(AbortedError);
});
});

View File

@@ -0,0 +1,76 @@
/**
* Abstract `PageObject` base class — typed wrapper around a Playwright
* `Page` exposing common patterns for the page-object pattern.
*
* Subclasses set `path` and add domain-specific locators / methods.
*/
import type { Locator, Page } from "@playwright/test";
import { TESTING_E2E_ERROR_CODES } from "../errors/codes.js";
import { AbortedError, NavigationError } from "../errors/testing-e2e-error.js";
export interface WaitForReadyOptions {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
}
export abstract class PageObject {
constructor(protected readonly page: Page) {}
/** Path the page object navigates to (relative to base URL). */
public abstract readonly path: string;
/** Navigate to {@link path}. Throws `NavigationError` on failure. */
public async navigate(): Promise<void> {
try {
await this.page.goto(this.path);
} catch (err) {
throw new NavigationError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
`PageObject.navigate to "${this.path}" failed`,
{ cause: err, context: { path: this.path } }
);
}
}
/**
* Wait for the page to reach a "ready" state. The default implementation
* waits for `domcontentloaded`. Subclasses may override.
*/
public async waitForReady(
options: WaitForReadyOptions = {}
): Promise<void> {
if (options.signal?.aborted) {
throw new AbortedError("waitForReady aborted via signal", {
cause: options.signal.reason,
context: { reason: options.signal.reason },
});
}
const waitOptions =
options.timeoutMs !== undefined
? { timeout: options.timeoutMs }
: undefined;
await this.page.waitForLoadState("domcontentloaded", waitOptions);
}
public findByRole(
role: Parameters<Page["getByRole"]>[0],
options?: Parameters<Page["getByRole"]>[1]
): Locator {
return this.page.getByRole(role, options);
}
public findByLabel(
text: Parameters<Page["getByLabel"]>[0],
options?: Parameters<Page["getByLabel"]>[1]
): Locator {
return this.page.getByLabel(text, options);
}
public findByText(
text: Parameters<Page["getByText"]>[0],
options?: Parameters<Page["getByText"]>[1]
): Locator {
return this.page.getByText(text, options);
}
}

View File

@@ -0,0 +1,6 @@
export {
STEALTH_DEFAULT_CLOAK,
STEALTH_HEADED_CLOAK,
stealthMax,
stealthMaxHeadless,
} from "./stealth-presets.js";

View File

@@ -0,0 +1,101 @@
# stealth-presets
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ready-made `CloakOptions` bundles for the most common cloakbrowser
deployment shapes. Pulled from the official CloakBrowser README's
"Recommended config for anti-bot sites" section. The presets are
plain frozen objects (or proxy-parameterised factories) so they can
be dropped straight into `playwright.config.ts`'s
`use.cloak`, into a programmatic `launchBrowserWithCloak(...)` call,
or merged with per-test overrides via spread.
## Features
- `STEALTH_DEFAULT_CLOAK` — headless baseline; equivalent to no
options (cloakbrowser auto-seeds + applies stealth args by default)
- `STEALTH_HEADED_CLOAK` — headed Chromium + humanized input; for
CI under Xvfb
- `stealthMax(proxy)` — maximum-stealth preset matching the upstream
recommendation: residential proxy, `geoip: true`, `headless: false`,
`humanize: true`. Beats Cloudflare Turnstile, DataDome, Akamai,
Kasada, ShieldSquare, reCAPTCHA v3 Enterprise
- `stealthMaxHeadless(proxy)` — same configuration but headless, for
Linux runners without a virtual framebuffer
## Usage: Basic Example
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { stealthMax } from "@suaveplan/testing-e2e";
export default defineConfig({
use: {
cloak: stealthMax(process.env.RESIDENTIAL_PROXY!),
},
});
```
## Usage: Advanced Example
```typescript
import {
extendedTest as test,
expect,
stealthMax,
launchBrowserWithCloak,
STEALTH_HEADED_CLOAK,
} from "@suaveplan/testing-e2e";
// Per-spec override — bump up to stealthMax just for this suite.
test.describe("checkout flow behind Cloudflare Turnstile", () => {
test.use({ cloak: stealthMax(process.env.PROXY!) });
test("checks out", async ({ freshPage }) => {
await freshPage.goto("https://shop.example.com");
await expect(freshPage).toHaveURL(/checkout/);
});
});
// Outside Playwright runner — same preset, programmatic launch.
const browser = await launchBrowserWithCloak({
...STEALTH_HEADED_CLOAK,
timezone: "America/New_York",
});
```
## API Reference
### `STEALTH_DEFAULT_CLOAK: Readonly<CloakOptions>`
`{ headless: true }`.
### `STEALTH_HEADED_CLOAK: Readonly<CloakOptions>`
`{ headless: false, humanize: true }`.
### `stealthMax(proxy: string): CloakOptions`
`{ headless: false, humanize: true, geoip: true, proxy }`.
### `stealthMaxHeadless(proxy: string): CloakOptions`
`{ headless: true, humanize: true, geoip: true, proxy }`.
## Implementation Notes
Static presets are produced via `Object.freeze(...)` so consumers
cannot accidentally mutate them between test runs. Factory functions
return fresh objects each call so the resulting `CloakOptions` can
be safely spread with per-suite overrides
(`{ ...stealthMax(proxy), timezone: 'Asia/Tokyo' }`).
`stealthMax` requires a proxy because the upstream recommendation
explicitly states "datacenter IPs get blocked by reputation alone";
the preset is intentionally non-default to prevent accidental use
without one.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
STEALTH_DEFAULT_CLOAK,
STEALTH_HEADED_CLOAK,
stealthMax,
stealthMaxHeadless,
} from "./stealth-presets.js";
describe("STEALTH_DEFAULT_CLOAK", () => {
it("is headless and frozen", { timeout: 5000 }, () => {
expect(STEALTH_DEFAULT_CLOAK).toEqual({ headless: true });
expect(Object.isFrozen(STEALTH_DEFAULT_CLOAK)).toBe(true);
});
});
describe("STEALTH_HEADED_CLOAK", () => {
it("is headed + humanized and frozen", { timeout: 5000 }, () => {
expect(STEALTH_HEADED_CLOAK).toEqual({
headless: false,
humanize: true,
});
expect(Object.isFrozen(STEALTH_HEADED_CLOAK)).toBe(true);
});
});
describe("stealthMax", () => {
it("matches the upstream recommended config", { timeout: 5000 }, () => {
expect(stealthMax("http://proxy:8080")).toEqual({
headless: false,
humanize: true,
geoip: true,
proxy: "http://proxy:8080",
});
});
it("returns a fresh object each call (safe to spread)", {
timeout: 5000,
}, () => {
const a = stealthMax("http://a:8080");
const b = stealthMax("http://b:8080");
expect(a).not.toBe(b);
// Override-by-spread does not affect the original return.
const merged = { ...a, timezone: "Asia/Tokyo" };
expect(merged).toMatchObject({
headless: false,
humanize: true,
geoip: true,
proxy: "http://a:8080",
timezone: "Asia/Tokyo",
});
expect(a).not.toHaveProperty("timezone");
});
});
describe("stealthMaxHeadless", () => {
it("matches stealthMax with headless: true", { timeout: 5000 }, () => {
expect(stealthMaxHeadless("socks5://u:p@h:1080")).toEqual({
headless: true,
humanize: true,
geoip: true,
proxy: "socks5://u:p@h:1080",
});
});
});

View File

@@ -0,0 +1,58 @@
import type { CloakOptions } from "../fixtures/extended-test.js";
/**
* Headless stealth baseline. Equivalent to passing no `cloak` options
* — the cloakbrowser binary auto-generates a random fingerprint seed
* and applies all default stealth args. Suitable for low-friction
* sites without bot scoring.
*/
export const STEALTH_DEFAULT_CLOAK = Object.freeze({
headless: true,
}) satisfies CloakOptions;
/**
* Headed Chromium with human-like input behaviour. Use under Xvfb on
* Linux CI. Trades performance for behavioural realism — clicks
* follow Bézier curves, keystrokes have per-character timing, scrolls
* accelerate/decelerate naturally.
*/
export const STEALTH_HEADED_CLOAK = Object.freeze({
headless: false,
humanize: true,
}) satisfies CloakOptions;
/**
* Maximum-stealth preset matching the CloakBrowser README's
* "Recommended config for anti-bot sites" block. Pass a residential
* proxy URL via {@link stealthMax} to unlock the full configuration:
* residential IP, geo-IP-derived timezone/locale, headed Chromium,
* humanized input.
*
* Use against Cloudflare Turnstile, DataDome, Akamai, Kasada,
* ShieldSquare, and reCAPTCHA v3 Enterprise. Pair with the font
* setup in `docker/cloakserve.Dockerfile` to defeat canvas-emoji
* checks on Kasada/Akamai.
*/
export function stealthMax(proxy: string): CloakOptions {
return {
headless: false,
humanize: true,
geoip: true,
proxy,
};
}
/**
* Like {@link stealthMax} but headless. Use when running on a
* Linux runner without a virtual framebuffer. Most sites still
* accept this; DataDome / Kasada / Akamai may still block — fall
* back to {@link stealthMax} under Xvfb if so.
*/
export function stealthMaxHeadless(proxy: string): CloakOptions {
return {
headless: true,
humanize: true,
geoip: true,
proxy,
};
}

View File

@@ -0,0 +1,182 @@
/**
* Spec-conformance pinning tests for `@suaveplan/testing-e2e`.
*
* Each `it(...)` block here corresponds to a `**Verified by:**`
* reference in `openspec/specs/testing-e2e/spec.md`. These tests
* pin invariants that are otherwise implicit in the public surface
* (Playwright primitive identity, error-code regex, factory defaults,
* helper signatures) so spec drift is caught at the unit-test layer.
*/
import {
Browser as PWBrowser,
expect as pwExpect,
request as pwRequest,
test as pwTest,
} from "@playwright/test";
import { describe, expect, it } from "vitest";
import {
AbortedError,
ConfigError,
createPlaywrightConfig,
dismissDialog,
expect as exportedExpect,
request as exportedRequest,
test as exportedTest,
FixtureError,
mockClipboard,
mockGeolocation,
NavigationError,
PageObject,
screenshotOnFailure,
TESTING_E2E_ERROR_CODE_PATTERN,
TESTING_E2E_ERROR_CODES,
TestingE2eError,
WaitTimeoutError,
waitForNetworkIdle,
} from "./index.js";
describe("spec-conformance: Playwright primitive re-exports", () => {
it("re-exports the original Playwright `test` symbol", {
timeout: 5000,
}, () => {
expect(exportedTest).toBe(pwTest);
});
it("re-exports the original Playwright `expect` symbol", {
timeout: 5000,
}, () => {
expect(exportedExpect).toBe(pwExpect);
});
it("re-exports the original Playwright `request` namespace", {
timeout: 5000,
}, () => {
expect(exportedRequest).toBe(pwRequest);
});
it("Browser type is sourced from @playwright/test", {
timeout: 5000,
}, () => {
// Type-only import is verified by the typecheck pass; we record a
// runtime touchpoint so the symbol is reachable in the bundle map.
const _typeWitness: unknown = PWBrowser;
expect(
_typeWitness === undefined || typeof _typeWitness === "function"
).toBe(true);
});
});
describe("spec-conformance: structured error codes", () => {
it("every code in the registry matches the canonical regex", {
timeout: 5000,
}, () => {
for (const value of Object.values(TESTING_E2E_ERROR_CODES)) {
expect(value, `code ${value} must match the regex`).toMatch(
TESTING_E2E_ERROR_CODE_PATTERN
);
}
});
it("AbortedError carries the TESTING_E2E.ABORTED code", {
timeout: 5000,
}, () => {
const err = new AbortedError("aborted");
expect(err).toBeInstanceOf(TestingE2eError);
expect(err.code).toBe(TESTING_E2E_ERROR_CODES.ABORTED);
expect(err.code).toMatch(TESTING_E2E_ERROR_CODE_PATTERN);
});
it("ConfigError / NavigationError / WaitTimeoutError / FixtureError extend TestingE2eError", {
timeout: 5000,
}, () => {
const errs = [
new ConfigError(TESTING_E2E_ERROR_CODES.CONFIG_INVALID, "x"),
new NavigationError(TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED, "x"),
new WaitTimeoutError(TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT, "x"),
new FixtureError(TESTING_E2E_ERROR_CODES.FIXTURE_SETUP_FAILED, "x"),
];
for (const err of errs) {
expect(err).toBeInstanceOf(TestingE2eError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toMatch(TESTING_E2E_ERROR_CODE_PATTERN);
}
});
});
describe("spec-conformance: createPlaywrightConfig stable defaults", () => {
it("default config has fullyParallel === false and trace === on-first-retry", {
timeout: 5000,
}, () => {
delete process.env["CI"];
const cfg = createPlaywrightConfig({
baseURL: "http://localhost:3000",
});
expect(cfg.fullyParallel).toBe(false);
const use = cfg.use as Record<string, unknown>;
expect(use.trace).toBe("on-first-retry");
expect(use.screenshot).toBe("only-on-failure");
expect(use.video).toBe("retain-on-failure");
});
it("CI mode sets forbidOnly:true, retries:2, workers:1", {
timeout: 5000,
}, () => {
process.env["CI"] = "1";
const cfg = createPlaywrightConfig();
expect(cfg.forbidOnly).toBe(true);
expect(cfg.retries).toBe(2);
expect(cfg.workers).toBe(1);
delete process.env["CI"];
});
it("caller override wins over CI defaults", { timeout: 5000 }, () => {
process.env["CI"] = "1";
const cfg = createPlaywrightConfig({ retries: 5 });
expect(cfg.retries).toBe(5);
delete process.env["CI"];
});
});
describe("spec-conformance: helper signatures", () => {
it("exports waitForNetworkIdle / screenshotOnFailure / dismissDialog / mockGeolocation / mockClipboard as functions", {
timeout: 5000,
}, () => {
expect(typeof waitForNetworkIdle).toBe("function");
expect(typeof screenshotOnFailure).toBe("function");
expect(typeof dismissDialog).toBe("function");
expect(typeof mockGeolocation).toBe("function");
expect(typeof mockClipboard).toBe("function");
});
it("waitForNetworkIdle has arity 2 (page, options?)", {
timeout: 5000,
}, () => {
expect(waitForNetworkIdle.length).toBe(1);
});
it("mockGeolocation has arity 2 (context, coords)", {
timeout: 5000,
}, () => {
expect(mockGeolocation.length).toBe(2);
});
it("mockClipboard has arity 1 (page)", {
timeout: 5000,
}, () => {
expect(mockClipboard.length).toBe(1);
});
});
describe("spec-conformance: PageObject abstract base", () => {
it("PageObject is a class (function with prototype)", {
timeout: 5000,
}, () => {
expect(typeof PageObject).toBe("function");
expect(PageObject.prototype.navigate).toBeTypeOf("function");
expect(PageObject.prototype.waitForReady).toBeTypeOf("function");
expect(PageObject.prototype.findByRole).toBeTypeOf("function");
expect(PageObject.prototype.findByLabel).toBeTypeOf("function");
expect(PageObject.prototype.findByText).toBeTypeOf("function");
});
});

View File

@@ -0,0 +1,66 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(here, "..", "package.json");
describe("dep-graph guard", () => {
it("no @suaveplan/* in dependencies or peerDependencies", {
timeout: 5000,
}, async () => {
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const sections: Array<
[
"dependencies" | "peerDependencies",
Record<string, string> | undefined,
]
> = [
["dependencies", pkg.dependencies],
["peerDependencies", pkg.peerDependencies],
];
for (const [name, deps] of sections) {
if (!deps) continue;
const offending = Object.keys(deps).filter((k) =>
k.startsWith("@suaveplan/")
);
expect(
offending,
`${name} contains @suaveplan/* deps: ${offending.join(", ")}`
).toEqual([]);
}
});
it("devDependencies may contain @suaveplan/testing only", {
timeout: 5000,
}, async () => {
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
devDependencies?: Record<string, string>;
};
const ours = Object.keys(pkg.devDependencies ?? {}).filter((k) =>
k.startsWith("@suaveplan/")
);
const allowed = new Set(["@suaveplan/testing"]);
for (const dep of ours) {
expect(
allowed.has(dep),
`unexpected @suaveplan/* devDep: ${dep}`
).toBe(true);
}
});
it("cloakbrowser is a direct dependency", { timeout: 5000 }, async () => {
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
dependencies?: Record<string, string>;
};
expect(pkg.dependencies?.cloakbrowser).toBeDefined();
// Either a catalog reference or an absolute range is acceptable.
expect(pkg.dependencies?.cloakbrowser).toMatch(
/^(catalog:testing|\^?\d|~?\d|\d)/
);
});
});

View File

@@ -0,0 +1,76 @@
import { readdir, readFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const srcRoot = resolve(here, "..", "src");
const PATTERNS: Array<{ name: string; regex: RegExp }> = [
{ name: "any", regex: /(?<![a-zA-Z0-9_])any(?![a-zA-Z0-9_])/ },
{ name: "ts-ignore", regex: /@ts-ignore/ },
{ name: "ts-expect-error", regex: /@ts-expect-error/ },
{ name: "TODO", regex: /\bTODO\b/ },
{ name: "FIXME", regex: /\bFIXME\b/ },
{ name: "@deprecated", regex: /@deprecated/ },
{ name: ".skip(", regex: /\.skip\s*\(/ },
{ name: ".only(", regex: /\.only\s*\(/ },
// waitForTimeout / wait_for_timeout emit CDP protocol traffic that
// reCAPTCHA v3 / Enterprise and other behavioural scorers
// detect — use a native sleep instead. Pattern matches both the
// Playwright JS form and any incidental Python form that might
// leak into TS via codegen.
{ name: "waitForTimeout", regex: /\bwaitForTimeout\s*\(/ },
{ name: "wait_for_timeout", regex: /\bwait_for_timeout\s*\(/ },
];
const EXCLUDED_FILE_SUFFIXES = [".test.ts", ".bench.ts"];
async function walk(dir: string, out: string[]): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, out);
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
out.push(full);
}
}
}
describe("no-disallowed source guard", () => {
it("src/ contains no disallowed constructs", {
timeout: 10000,
}, async () => {
const all: string[] = [];
await walk(srcRoot, all);
const files = all.filter((file) => {
const rel = relative(srcRoot, file).replace(/\\/g, "/");
return !EXCLUDED_FILE_SUFFIXES.some((s) => rel.endsWith(s));
});
const violations: string[] = [];
for (const file of files) {
const text = await readFile(file, "utf8");
const lines = text.split("\n");
const rel = relative(srcRoot, file).replace(/\\/g, "/");
for (const { name, regex } of PATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const trimmed = line.trim();
if (
trimmed.startsWith("//") ||
trimmed.startsWith("*") ||
trimmed.startsWith("/*")
)
continue;
if (regex.test(line)) {
violations.push(
`${rel}:${i + 1} contains "${name}": ${trimmed}`
);
}
}
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,50 @@
/**
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
* environments on WSL2 or older toolchains.
*
* Import via vitest `setupFiles`:
*
* ```ts
* // vitest.config.ts
* export default defineConfig({
* test: {
* setupFiles: ["@suaveplan/testing/polyfills"],
* },
* });
* ```
*
* Installs:
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
*
* Exports nothing. Safe to import multiple times; operations are idempotent.
*/
import { webcrypto } from "node:crypto";
import {
TextDecoder as NodeTextDecoder,
TextEncoder as NodeTextEncoder,
} from "node:util";
type WritableGlobal = {
crypto?: Crypto;
TextEncoder?: typeof NodeTextEncoder;
TextDecoder?: typeof NodeTextDecoder;
};
const g = globalThis as unknown as WritableGlobal;
if (
typeof g.crypto === "undefined" ||
typeof g.crypto.getRandomValues !== "function"
) {
g.crypto = webcrypto as unknown as Crypto;
}
if (typeof g.TextEncoder === "undefined") {
g.TextEncoder = NodeTextEncoder;
}
if (typeof g.TextDecoder === "undefined") {
g.TextDecoder = NodeTextDecoder;
}

View File

@@ -0,0 +1,40 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = dirname(fileURLToPath(import.meta.url));
const distRoot = resolve(here, "..", "dist");
async function distExists(): Promise<boolean> {
try {
await readFile(resolve(distRoot, "index.js"), "utf8");
return true;
} catch {
return false;
}
}
async function readDist(rel: string): Promise<string> {
return readFile(resolve(distRoot, rel), "utf8");
}
describe("tree-shake guards", () => {
it("page-object subpath does not pull config", {
timeout: 10000,
}, async () => {
if (!(await distExists())) return;
const text = await readDist("page-objects/page-object.js");
expect(text).not.toMatch(/createPlaywrightConfig/);
expect(text).not.toMatch(/waitForNetworkIdle/);
});
it("wait-for-network-idle does not pull dialog helper", {
timeout: 10000,
}, async () => {
if (!(await distExists())) return;
const text = await readDist("helpers/wait-for-network-idle.js");
expect(text).not.toMatch(/dismissDialog/);
expect(text).not.toMatch(/mockGeolocation/);
});
});

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,54 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/testing-e2e",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"config/create-playwright-config": "src/config/create-playwright-config.ts",
"console-monitor/index": "src/console-monitor/index.ts",
"errors/index": "src/errors/index.ts",
"extension-fixture/index": "src/extension-fixture/index.ts",
"fixtures/extended-test": "src/fixtures/extended-test.ts",
"cloakserve/index": "src/cloakserve/index.ts",
"presets/index": "src/presets/index.ts",
"helpers/index": "src/helpers/index.ts",
"multi-session/index": "src/multi-session/index.ts",
"network-monitor/index": "src/network-monitor/index.ts",
"observability/index": "src/observability/index.ts",
"page-objects/page-object": "src/page-objects/page-object.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@playwright/test",
"cloakbrowser",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vitest/config";
// Inline config — testing-e2e tests run under vitest (NOT Playwright).
// The package wraps Playwright; its unit tests verify the wrappers' shape
// and error behaviour using mocked Playwright primitives.
export default defineConfig({
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: [
"src/**/*.test.ts",
"tests/**/*.test.ts",
],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"src/setup/polyfills.ts",
"src/fixtures/extended-test.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});