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

164 lines
11 KiB
Markdown

# testing-e2e
## Purpose
The `@suaveplan/testing-e2e` package provides Playwright end-to-end testing utilities: a `createPlaywrightConfig()` factory with stable defaults (trace-on-first-retry, screenshot-on-failure, video-retain-on-failure, CI-mode overrides), a single-import re-export surface for Playwright primitives (`test`, `expect`, `Page`, `Browser`, `BrowserContext`, `Locator`, `request`), an abstract `PageObject` base class with locator helpers and abort-aware navigation, browser-free `apiContext` fixtures, and a suite of helpers (`waitForNetworkIdle`, `screenshotOnFailure`, `dismissDialog`, `mockGeolocation`, `mockClipboard`). The package is a Layer 0 dependency with zero `@suaveplan/*` workspace dependencies; all errors flow through `TestingE2eError` with stable `TESTING_E2E.<DOMAIN>.<REASON>` codes.
## Requirements
### Requirement: Layer 0 dep-graph isolation
The `@suaveplan/testing-e2e` `dependencies` and `peerDependencies` MUST NOT contain any `@suaveplan/*` workspace package. `devDependencies` MAY include `@suaveplan/testing` for the package's own tests.
#### Scenario: Runtime closure clean
- **WHEN** `bunx turbo ls --filter=@suaveplan/testing-e2e...` is run
- **THEN** no `@suaveplan/*` package appears in the runtime closure
**Verified by:** `tests/dep-graph.test.ts::"no @suaveplan/* in dependencies or peerDependencies"` AND `tests/dep-graph.test.ts::"devDependencies may contain @suaveplan/testing only"`
### Requirement: Playwright primitive re-exports
The package MUST re-export `test`, `expect`, `Page`, `Browser`, `BrowserContext`, `Locator`, `request` from `@playwright/test` so consumers import a single entry point.
#### Scenario: Single-import consumer
- **WHEN** a consumer imports `{ test, expect, Page }` from `@suaveplan/testing-e2e`
- **THEN** the symbols are the Playwright originals (identity comparison passes)
**Verified by:** `src/spec-conformance.test.ts::"re-exports the original Playwright \`test\` symbol"` AND `src/spec-conformance.test.ts::"re-exports the original Playwright \`expect\` symbol"` AND `src/spec-conformance.test.ts::"re-exports the original Playwright \`request\` namespace"` AND `src/spec-conformance.test.ts::"Browser type is sourced from @playwright/test"`
### Requirement: `createPlaywrightConfig` produces stable defaults
The factory SHALL emit a `PlaywrightTestConfig` with `fullyParallel: false`, `use.trace: "on-first-retry"`, `use.screenshot: "only-on-failure"`, `use.video: "retain-on-failure"`. CI mode (detected via `process.env.CI`) MUST set `forbidOnly: true`, `retries: 2`, `workers: 1`. Caller-supplied overrides MUST take precedence.
#### Scenario: Default config
- **WHEN** `createPlaywrightConfig({ baseURL: "http://localhost:3000" })` is called
- **THEN** the returned config has `fullyParallel === false` AND `use.trace === "on-first-retry"`
#### Scenario: CI overrides
- **WHEN** the factory runs with `process.env.CI === "1"` and no override
- **THEN** the config has `forbidOnly === true`, `retries === 2`, `workers === 1`
#### Scenario: Caller override wins
- **WHEN** `createPlaywrightConfig({ retries: 5 })` is called with `process.env.CI === "1"`
- **THEN** the config has `retries === 5`
**Verified by:** `src/config/create-playwright-config.test.ts::"sets fullyParallel: false"` AND `src/config/create-playwright-config.test.ts::"applies trace/screenshot/video defaults via use"` AND `src/config/create-playwright-config.test.ts::"sets forbidOnly, retries, workers in CI"` AND `src/config/create-playwright-config.test.ts::"respects caller overrides over CI defaults"`
### Requirement: `PageObject` base class
`PageObject` MUST be an abstract class accepting a Playwright `Page` in its constructor. It MUST expose `path: string` (abstract), `navigate(): Promise<void>`, `waitForReady(opts?): Promise<void>`, and convenience locator helpers (`findByRole`, `findByLabel`, `findByText`).
#### Scenario: Subclass + navigate
- **WHEN** a subclass extends `PageObject`, sets `path = "/users"`, and calls `await instance.navigate()`
- **THEN** `page.goto("/users")` is called once
#### Scenario: waitForReady aborts on signal
- **WHEN** `waitForReady({ signal })` is called with a pre-aborted signal
- **THEN** the promise rejects with `AbortedError` whose `code === "TESTING_E2E.ABORTED"`
**Verified by:** `src/page-objects/page-object.test.ts::"navigate calls page.goto with the configured path"` AND `src/page-objects/page-object.test.ts::"waitForReady delegates to page.waitForLoadState"` AND `src/page-objects/page-object.test.ts::"findByRole / findByLabel / findByText delegate"` AND `src/page-objects/page-object.test.ts::"waitForReady throws AbortedError on pre-aborted signal"` AND `src/spec-conformance.test.ts::"PageObject is a class (function with prototype)"`
### Requirement: Helpers expose stable signatures
The following helpers MUST be exported with the signatures below and SHALL throw `TestingE2eError` subclasses on failure (never plain `Error`):
- `waitForNetworkIdle(page, options?): Promise<void>` — waits until the page sees `idleMs` of zero in-flight requests
- `screenshotOnFailure(testInfo, page): Promise<void>` — captures a screenshot named after the failing test
- `dismissDialog(page, action?): Promise<void>` — registers a one-shot dialog handler that auto-accepts (or auto-dismisses if `action === "dismiss"`)
- `mockGeolocation(context, coords): Promise<void>` — installs a static geolocation override
- `mockClipboard(page): Promise<void>` — installs a programmatic clipboard
#### Scenario: waitForNetworkIdle resolves
- **WHEN** the predicate observes zero in-flight requests for the configured `idleMs` window
- **THEN** the promise resolves
#### Scenario: waitForNetworkIdle times out
- **WHEN** the network never quiets within `timeoutMs`
- **THEN** the promise rejects with `WaitTimeoutError` whose `code === "TESTING_E2E.WAIT.TIMEOUT"`
**Verified by:** `src/spec-conformance.test.ts::"exports waitForNetworkIdle / screenshotOnFailure / dismissDialog / mockGeolocation / mockClipboard as functions"` AND `src/helpers/wait-for-network-idle.test.ts::"resolves when network is idle for idleMs"` AND `src/helpers/wait-for-network-idle.test.ts::"throws WaitTimeoutError when network never settles"`
### Requirement: Structured errors with stable codes
All errors thrown from `@suaveplan/testing-e2e` MUST be subclasses of `TestingE2eError extends Error` carrying `{ code, cause?, context? }`. Codes match `^TESTING_E2E\.[A-Z]+\.[A-Z_]+$`.
#### Scenario: Coded error
- **WHEN** any helper throws
- **THEN** the thrown value `instanceof TestingE2eError` is true AND `err.code` matches the regex
**Verified by:** `src/errors/testing-e2e-error.test.ts::"stores code, message, cause, context"` AND `src/errors/testing-e2e-error.test.ts::"each preserves instanceof TestingE2eError"` AND `src/errors/testing-e2e-error.test.ts::"every value matches the pattern"` AND `src/spec-conformance.test.ts::"every code in the registry matches the canonical regex"`
### Requirement: Coverage and docs gates
Istanbul statement + branch + function + line coverage across `packages/testing/testing-e2e/src/` (excluding `*.bench.ts`, `*.schemas.ts`, barrel `index.ts` files) MUST be 100% on every dimension. Every implementation file MUST have a sibling `.md` (≥ 200 words, simple + advanced examples).
#### Scenario: CI coverage gate
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/testing-e2e` runs
- **THEN** statement / branch / function / line coverage all = 100%
**Verified by:** CI gate (`bunx turbo test:coverage --filter=@suaveplan/testing-e2e` exits non-zero on regression) — current run reports 100/100/100/100
### Requirement: Test placement follows the genesis hard rule
Per-feature unit tests SHALL be co-located in `src/` (named `<feature>.test.ts` next to `<feature>.ts`). Cross-cutting tests (dep-graph guard, tree-shake guard, no-disallowed grep guard) MUST live in `tests/` sibling to `src/`.
#### Scenario: Per-feature co-location
- **WHEN** `src/page-objects/page-object.ts` exists
- **THEN** its unit tests live at `src/page-objects/page-object.test.ts`
#### Scenario: Cross-cutting in tests/
- **WHEN** a guard exercises package-level concerns (dep graph, tree-shake, lint)
- **THEN** it lives under `tests/`
**Verified by:** directory layout audit (`tests/` contains dep-graph, tree-shake, no-disallowed; `src/` contains per-feature `*.test.ts(x)` co-located with implementation)
### Requirement: `extendedTest.apiContext` is browser-free
The `apiContext` fixture in `extendedTest` SHALL NOT request the `browser` fixture from Playwright's dependency graph. Playwright spins up requested deps eagerly, so destructuring `browser` would launch a Chromium instance for every API-only test, blowing the per-suite startup budget.
#### Scenario: Source-level guard
- **WHEN** `src/fixtures/extended-test.ts` is parsed
- **THEN** the apiContext fixture body destructures an empty object AND its destructure list contains no `browser` token
#### Scenario: extendedTest export shape
- **WHEN** a consumer imports `extendedTest` from `@suaveplan/testing-e2e/fixtures`
- **THEN** the value is a callable Playwright test extension exposing `.extend(...)`
**Verified by:** `src/fixtures/extended-test.test.ts::"apiContext fixture does not destructure \`browser\`"` AND `src/fixtures/extended-test.test.ts::"extendedTest is exported as a Playwright test extension"`
### Requirement: `instrumented()` ends spans only after async fns settle
`instrumented(name, fn)` SHALL detect when the wrapped function returns a PromiseLike value and SHALL defer `span.end()` and the duration histogram record until the returned promise settles. On rejection, the span SHALL `recordException` (wrapping non-Error rejections in `new Error(String(reason))`) before ending. On resolution, the duration recorded SHALL reflect the wall-clock interval between invocation and settlement.
#### Scenario: async resolve
- **WHEN** the wrapped fn awaits ≥10ms before returning
- **THEN** the awaited result equals the inner return value AND the elapsed wall-clock time is ≥10ms
#### Scenario: async reject
- **WHEN** the wrapped async fn throws an `Error`
- **THEN** the returned promise rejects with the same error
#### Scenario: async reject with non-Error reason
- **WHEN** the wrapped async fn rejects with a string
- **THEN** the returned promise rejects with the same string AND `recordException` is invoked with a wrapped `Error`
**Verified by:** `src/observability/instrumented.test.ts::"ends span only after async fn settles (resolved)"` AND `src/observability/instrumented.test.ts::"ends span only after async fn settles (rejected)"` AND `src/observability/instrumented.test.ts::"ends span only after async fn settles (rejected with non-Error)"`