Files
suaveplan/openspec/changes/swap-testing-e2e-cloakbrowser/specs/testing-e2e/spec.md
2026-05-27 23:29:53 +01:00

213 lines
10 KiB
Markdown

# testing-e2e — delta (swap-testing-e2e-cloakbrowser)
## ADDED Requirements
### Requirement: Cloakserve CDP connect helper
The package MUST expose `@suaveplan/testing-e2e/cloakserve` (also
re-exported from the package root) providing `connectCloakServe(options, chromium?)`
and `buildCloakServeUrl(options)`. The URL builder MUST map camelCase
option keys to cloakserve's kebab-case query-string keys
(`hardwareConcurrency` → `hardware-concurrency`, etc.), stringify
booleans, skip `undefined`/`null` values, and default the base URL to
`http://localhost:9222`. `connectCloakServe` MUST inject `@playwright/test`'s
`chromium` by default with a lazy dynamic-import cache, and wrap any
`connectOverCDP` rejection in a `CloakLaunchError` whose `context`
carries `{ cloakServeUrl }`.
#### Scenario: buildCloakServeUrl maps keys and base URL
- **WHEN** `buildCloakServeUrl({ fingerprint: 42, hardwareConcurrency: 8, geoip: true })` is called
- **THEN** the result equals `http://localhost:9222/?fingerprint=42&hardware-concurrency=8&geoip=true` (param order is preserved)
**Verified by:** `src/cloakserve/build-cloakserve-url.test.ts::"appends fingerprint seed"` AND `src/cloakserve/build-cloakserve-url.test.ts::"maps camelCase to kebab-case"` AND `src/cloakserve/build-cloakserve-url.test.ts::"stringifies booleans"`
#### Scenario: connectCloakServe forwards options and returns the Browser
- **WHEN** `connectCloakServe({ fingerprint: 11111, timezone: "America/New_York" }, injectedDriver)` is called
- **THEN** `injectedDriver.connectOverCDP` is called with a URL containing `fingerprint=11111` and `timezone=America%2FNew_York`, and the returned `Browser` is the injected fake
**Verified by:** `src/cloakserve/cloakserve-connect.test.ts::"uses injected chromium and builds the cloakserve URL"`
#### Scenario: connectCloakServe wraps errors
- **WHEN** the underlying `connectOverCDP` rejects with `ECONNREFUSED`
- **THEN** `connectCloakServe` rethrows a `CloakLaunchError` with
`code === TESTING_E2E.CLOAK.PROXY_INVALID` and
`context.cloakServeUrl` set to the URL that was attempted
**Verified by:** `src/cloakserve/cloakserve-connect.test.ts::"wraps connection errors in CloakLaunchError"` AND `src/cloakserve/cloakserve-connect.test.ts::"classifies generic connect errors as LAUNCH_FAILED"`
### Requirement: Stealth presets
The package MUST expose `@suaveplan/testing-e2e/presets` (also
re-exported from the package root) providing frozen `STEALTH_DEFAULT_CLOAK`
(`{ headless: true }`) and `STEALTH_HEADED_CLOAK`
(`{ headless: false, humanize: true }`) plus `stealthMax(proxy)` and
`stealthMaxHeadless(proxy)` factories that return fresh objects on
every call. `stealthMax(proxy)` MUST return
`{ headless: false, humanize: true, geoip: true, proxy }` — exactly
matching the CloakBrowser README's "Recommended config for anti-bot
sites" block.
#### Scenario: stealthMax matches the upstream recommendation
- **WHEN** `stealthMax("http://proxy:8080")` is called
- **THEN** the result is `{ headless: false, humanize: true, geoip: true, proxy: "http://proxy:8080" }`
**Verified by:** `src/presets/stealth-presets.test.ts::"matches the upstream recommended config"`
#### Scenario: static presets are frozen
- **WHEN** `Object.isFrozen(STEALTH_DEFAULT_CLOAK)` is checked
- **THEN** it is `true` (and same for `STEALTH_HEADED_CLOAK`)
**Verified by:** `src/presets/stealth-presets.test.ts::"is headless and frozen"` AND `src/presets/stealth-presets.test.ts::"is headed + humanized and frozen"`
### Requirement: `waitForTimeout` lint guard
The package's source-guard test MUST flag `waitForTimeout` and
`wait_for_timeout` calls anywhere under `src/` (excluding `.test.ts`
and `.bench.ts`). Both forms emit CDP protocol traffic that
reCAPTCHA v3 / Enterprise behavioural scorers detect — the upstream
CloakBrowser README documents this as a primary cause of low scores.
#### Scenario: guard fires on a synthetic violation
- **WHEN** the `no-disallowed source guard` test runs against the
current `src/` tree
- **THEN** the test passes (no violations) AND the `PATTERNS` array
contains entries named `waitForTimeout` and `wait_for_timeout`
**Verified by:** `tests/no-disallowed.test.ts::"src/ contains no disallowed constructs"`
### Requirement: Docker bundle
The package MUST ship a `docker/` directory containing
`Dockerfile.cloakserve` (extending `cloakhq/cloakbrowser`, installing
the Kasada / Akamai font set, running `cloakserve` on `9222`),
`Dockerfile.manager` (extending `cloakhq/cloakbrowser-manager`,
exposing 8080 with a `cloakprofiles` named volume),
`docker-compose.yml` (both services bound to `127.0.0.1`, manager
behind the `manager` compose profile, named volume defined),
`.env.example` (`CLOAKBROWSER_TAG`, `MANAGER_TAG`,
`MANAGER_AUTH_TOKEN`), and a `docker/README.md` operator playbook.
Both Dockerfiles MUST declare a healthcheck.
#### Scenario: cloakserve Dockerfile builds on the upstream image and exposes 9222
- **WHEN** `docker build -f docker/Dockerfile.cloakserve` is run
- **THEN** the resulting image is layered on `cloakhq/cloakbrowser`,
exposes port `9222`, defaults its CMD to `cloakserve`, and runs the
documented font-install step
**Verified by:** manual operator verification (`docker compose up cloakserve` + smoke connect via `connectCloakServe`); source-level invariants are enforced by reading the Dockerfile in CI is out of scope for this change
### Requirement: CLOAK_* error codes
The package MUST emit `CloakLaunchError extends TestingE2eError` with
codes `TESTING_E2E.CLOAK.LAUNCH_FAILED`,
`TESTING_E2E.CLOAK.BINARY_MISSING`, and
`TESTING_E2E.CLOAK.PROXY_INVALID`. All three codes MUST appear in the
exported `TESTING_E2E_ERROR_CODES` registry and conform to
`TESTING_E2E_ERROR_CODE_PATTERN`.
#### Scenario: codes registered
- **WHEN** `TESTING_E2E_ERROR_CODES` is iterated
- **THEN** the three CLOAK codes are present and each matches the
registry pattern
**Verified by:** `src/errors/testing-e2e-error.test.ts::"registers CLOAK_* codes"` AND `src/errors/testing-e2e-error.test.ts::"CloakLaunchError exposes the LAUNCH_FAILED code by default"`
#### Scenario: BINARY_MISSING raised when cloakbrowser binary not present
- **WHEN** the underlying cloakbrowser launcher throws an error whose
message includes `binary not found`, `ENOENT`, or `Failed to download`
- **THEN** the calling fixture rethrows a `CloakLaunchError` with code
`TESTING_E2E.CLOAK.BINARY_MISSING`, the original error as `cause`,
and a remediation hint pointing to `npx cloakbrowser install`
**Verified by:** `src/errors/testing-e2e-error.test.ts::"classifyCloakError maps ENOENT to BINARY_MISSING"`
## MODIFIED Requirements
### Requirement: Layer 0 dep-graph isolation
The `@suaveplan/testing-e2e` `dependencies` and `peerDependencies` MUST
NOT contain any `@suaveplan/*` workspace package. `dependencies` MUST
include `cloakbrowser` (any valid range — direct or `catalog:testing`)
alongside the existing `@playwright/test`, `@opentelemetry/api`,
`tslib`, and `zod` entries. `devDependencies` MAY include
`@suaveplan/testing` for the package's own tests.
#### Scenario: runtime closure clean and contains cloakbrowser
- **WHEN** `bunx turbo ls --filter=@suaveplan/testing-e2e...` is run
- **THEN** no `@suaveplan/*` package appears in the runtime closure
AND `cloakbrowser` is present as a direct dependency
**Verified by:** `tests/dep-graph.test.ts::"no @suaveplan/* in dependencies or peerDependencies"` AND `tests/dep-graph.test.ts::"cloakbrowser is a direct dependency"`
### Requirement: `extendedTest` default browser is stealth
`extendedTest.browser` MUST be overridden so the default browser used
by every fixture downstream (`freshPage`, consumer-extended fixtures)
launches via `cloakbrowser.launch()` (drop-in for stock chromium).
The override MUST honour `testInfo.project.use.cloak` if defined,
otherwise launch with cloakbrowser defaults (`headless: true`, stealth
args on). The launcher MUST be injectable for unit testing — the
fixture takes an internal default but exposes the launcher symbol on
the `cloak` worker fixture so tests can supply a spy.
#### Scenario: default browser is cloakbrowser
- **WHEN** a test runs under `extendedTest` with no per-project
override
- **THEN** the `browser` fixture is produced by the cloakbrowser
`launch` function (identity check via injected spy)
**Verified by:** `src/fixtures/extended-test.test.ts::"browser fixture is launched via cloakbrowser launch"` AND `src/fixtures/extended-test.test.ts::"cloak options from testInfo.project.use.cloak flow into launch"`
#### Scenario: errors map to CloakLaunchError
- **WHEN** the underlying cloakbrowser launcher throws
- **THEN** the fixture rethrows a `CloakLaunchError` with a classified
code (`LAUNCH_FAILED`, `BINARY_MISSING`, or `PROXY_INVALID`)
**Verified by:** `src/fixtures/extended-test.test.ts::"wraps launch errors in CloakLaunchError"`
### Requirement: Extension fixture uses cloakbrowser launchPersistentContext
`launchWithExtension` MUST accept a `launchPersistentContext` function
parameter (default: cloakbrowser's). The legacy
`chromium: BrowserType` parameter is removed. Extension args
(`--disable-extensions-except`, `--load-extension`) and userDataDir
behaviour are unchanged.
#### Scenario: default launcher is cloakbrowser
- **WHEN** `launchWithExtension({ extensionPath, userDataDir })` is
called with no explicit launcher
- **THEN** `cloakbrowser.launchPersistentContext` is invoked with the
expected `userDataDir` and Chromium args
**Verified by:** `src/extension-fixture/launch-with-extension.test.ts::"defaults to cloakbrowser launchPersistentContext"`
#### Scenario: launcher is injectable
- **WHEN** `launchWithExtension({ launchPersistentContext: spy, extensionPath, userDataDir })` is called
- **THEN** the provided `spy` is invoked exactly once with the
expected `userDataDir` and Chromium args
**Verified by:** `src/extension-fixture/launch-with-extension.test.ts::"uses injected launcher when provided"`
#### Scenario: launchPersistentContext errors map to CloakLaunchError
- **WHEN** the underlying launcher throws an error matching the
`ENOENT`/binary classifier
- **THEN** `launchWithExtension` rethrows a `CloakLaunchError` with
`code === TESTING_E2E.CLOAK.BINARY_MISSING`
**Verified by:** `src/extension-fixture/launch-with-extension.test.ts::"wraps launcher errors with CloakLaunchError"`