Files
2026-05-27 23:29:53 +01:00

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 === 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`"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 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)"