Skip to main content

E2E testing

Constellation runs a Playwright end-to-end suite across all four apps — Catalog, Directory, Project Tracker, and Wiki. This page covers how it runs in CI, how to run it locally, and how to add a test for a new screen. Everything here is the human-facing version of the agent-oriented e2e-testing skill in .claude/skills/e2e-testing/.

The required tier: smoke specs​

Every spec named *.smoke.spec.ts is a required merge gate — it runs in the E2E Smoke GitHub Actions job on every pull request, and the job must pass before merge. Everything else under an app's tests/e2e/ directory is advisory: useful locally, not enforced in CI (a broader nightly sweep is tracked separately).

The convention is deliberately narrow: one smoke spec per critical GUI path, kept fast (well under a minute) and self-contained, asserting that the primary authenticated surface actually renders rather than exercising every edge case.

How CI runs it​

The E2E gate is split into four independent, change-scoped legs that fan into one stable required check named E2E Smoke. A detect-e2e-zones job looks at which paths a PR touched and decides which legs actually need to run — a Catalog-only change runs only the Catalog leg; a change to a shared package or the CI workflow itself runs all four; a docs- or spec-only PR runs none (the aggregator still reports green, so the gate never hangs waiting on a leg that was never going to run). Pushes to develop/main always run all four legs as an integration backstop.

Each leg builds its app with a production build — next build followed by next start — rather than next dev, then runs every *.smoke.spec.ts for that app against the running server:

AppPortWhat it asserts
Project Tracker3002Saved-filter round-trip, parents view, tasks page
Directory + Project Tracker (coordinator pair)3001/3002Coordinator drawer, full-page view, artifact promotion (cross-zone)
Catalog3010Authenticated catalog home surface renders
Wiki3020Authenticated wiki shell renders (including the empty-wiki state)

Each leg has its own Postgres service and applies its own app's schema migrations, then boots the production server through the same warm-up scripts used previously — probing stays gated on the server actually logging that it's ready, and the leg restarts once if it gets stuck serving 404s for a route that should exist. On failure, the job uploads the raw server logs and the app's Playwright HTML report as CI artifacts so the failure is diagnosable without re-running.

Serving a production build requires the mock auth provider to remain reachable outside development mode. CI's serve and test steps set a narrowly-scoped, CI-only environment variable that a shared policy in @constellation-platform/auth-core verifies before allowing this — see that package's README for the full guard model. The variable is set only on those CI steps and must never appear in a real deployment's environment.

Wall-time improvement​

Moving to change-scoped parallel legs and production builds cuts typical PR wall time from roughly 15–18 minutes (all four apps, sequentially, against next dev) down to about 4–6 minutes for a typical single-module PR — mostly the time for next build, often a Turbo remote-cache hit. A PR that touches a shared package or the workflow itself still runs all four legs, but now in parallel rather than sequentially, so the worst case is roughly the slowest single leg (about 6–10 minutes) instead of the sum of all four. Moving off next dev also eliminates the turbopack cold-compile flake class from the test path entirely — a Rust panic in turbo-tasks-backend that could previously kill a zone mid-run on completely unrelated code.

Running locally​

From the repo root:

docker-compose -f docker-compose.dev.yml up -d # Postgres, MinIO
npx turbo run build --filter=@constellation-platform/* # build shared testing package (dist/)
cd apps/project-tracker && npm run db:setup # seeds the shared identity fixtures

The platform build is required once on a fresh checkout: each app's playwright.config.ts and its specs import from the shared @constellation-platform/testing package, which resolves to its dist/ output.

Then, from any app directory (apps/catalog, apps/directory, apps/project-tracker, apps/wiki):

npm run test:e2e

Playwright boots the app's dev server itself (via each app's playwright.config.ts) — there's no need to start npm run dev by hand first. If the app isn't Project Tracker, also run that app's own npm run db:migrate once so its schema exists.

Adding a test for a new screen​

When a PR adds a new authenticated UI surface, add a *.smoke.spec.ts for its critical path (or extend an existing one). The convention:

  1. Authenticate programmatically. Every spec logs in via a shared helper that POSTs to the app's mock login route and stores the returned session cookie — no spec drives the login form directly. This keeps auth fast and removes an entire class of login-related flake.

  2. Prefer API assertions over DOM assertions where they prove the same thing — checking an authenticated API response is faster and more stable than waiting on a render. Reserve DOM assertions for what's genuinely visual: does the shell render at all, does a specific control appear.

  3. Use accessible locators (role, label) rather than CSS selectors. Where an element is ambiguous or dynamically generated, add a data-testid following the module's naming convention, rather than reaching for a brittle selector.

  4. Explore the real UI before writing the spec. Boot the app locally and interact with the live page to find the actual locators, rather than guessing from the component source — the DOM the browser renders is the source of truth for a locator, not the JSX.

  5. A data-testid identifies an element only while exactly one element carries it. A placeholder rendered from more than one mount — the common case being a skeleton shared by a route's loading.tsx boundary and the surface it stands in for — puts two identical testids in the DOM, and the testid then names a class of element rather than one. Give each mount a discriminating attribute and select on both, so the assertion says which mount it means. Do not reach for .first() or a :visible filter: both pick whichever copy the timing produced, which is the same ambiguity with the failure suppressed rather than the meaning added.

    This is not hypothetical and it is not only a test concern. React streams a resolved segment into a hidden staging container before swapping it for the boundary's fallback, so both copies are attached while only one paints — invisible to the user, invisible to getByRole (the hidden copy is out of the accessibility tree), and visible to any attribute or CSS locator. A loaded CI runner widens that window until the assertion lands inside it every time, which reads as flake against a tree that is not changing.

Full detail, including the exact helper import path and the CI boot-script layout, lives in .claude/skills/e2e-testing/SKILL.md in the repository.

  • Request lifecycle — how one request flows through the layers a smoke spec exercises end-to-end.
  • Tenancy & RLS — the tenant-isolation rules the seeded test identities operate under.
  • Module references: Catalog, Wiki.