Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 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,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,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 @@
# 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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.