11 KiB
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"ANDsrc/spec-conformance.test.ts::"re-exports the original Playwright `expect` symbol"ANDsrc/spec-conformance.test.ts::"re-exports the original Playwright `request` namespace"ANDsrc/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 === falseANDuse.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 withprocess.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, setspath = "/users", and callsawait 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
AbortedErrorwhosecode === "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 seesidleMsof zero in-flight requestsscreenshotOnFailure(testInfo, page): Promise<void>— captures a screenshot named after the failing testdismissDialog(page, action?): Promise<void>— registers a one-shot dialog handler that auto-accepts (or auto-dismisses ifaction === "dismiss")mockGeolocation(context, coords): Promise<void>— installs a static geolocation overridemockClipboard(page): Promise<void>— installs a programmatic clipboard
Scenario: waitForNetworkIdle resolves
- WHEN the predicate observes zero in-flight requests for the configured
idleMswindow - THEN the promise resolves
Scenario: waitForNetworkIdle times out
- WHEN the network never quiets within
timeoutMs - THEN the promise rejects with
WaitTimeoutErrorwhosecode === "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 TestingE2eErroris true ANDerr.codematches 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-e2eruns - 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.tsexists - 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.tsis parsed - THEN the apiContext fixture body destructures an empty object AND its destructure list contains no
browsertoken
Scenario: extendedTest export shape
- WHEN a consumer imports
extendedTestfrom@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`"ANDsrc/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
recordExceptionis invoked with a wrappedError
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)"