test(e2e): Playwright harness and smoke suite #329

Merged
james merged 2 commits from test/e2e-playwright-foundation into main 2026-06-28 20:28:29 +00:00
Owner

What

Stands up Carol's first browser-level e2e: a new @carol/e2e workspace (apps/e2e/) with a Playwright harness that boots the assembled app and one smoke spec that drives it end to end. This is the foundation slice of #150 — broader coverage is split into child tickets.

Closes #150

How the harness boots the app

Playwright's webServer runs scripts/serve.sh, which is build-aware and idempotent:

  1. pnpm -F @carol/client export:web if apps/client/dist is missing.
  2. pnpm -F @carol/api build if apps/api/.next is missing.
  3. pnpm -F @carol/api start — the Next.js API serving /api/* and the built Expo RN-Web SPA (SPA_BUNDLE_PATH=apps/client/dist) against a throwaway file-backed SQLite under apps/e2e/.tmp/, wiped on every boot.

Readiness gates on GET /api/health; reuseExistingServer is on outside CI. Chromium + SQLite only; retries: 2 and trace: on-first-retry in CI.

A note on the DB: the plan called for sqlite::memory:, but next start applies migrations from the instrumentation hook, whose module graph is separate from the API route handlers in a production build — a libsql :memory: DB isn't shared across those two module instances, so requests hit an unmigrated (empty) DB ("no such table: users"). A throwaway file DB is shared by every connection in the process and still fresh per boot, so it preserves the plan's intent while actually working. Documented in serve.sh and the workspace README.

Smoke flow (tests/smoke.spec.ts)

One ordered spec: register a uniqueEmail() user (fresh DB ⇒ first user is admin, lands authenticated at /notes) → assert the sidebar renders → create a note and assert it appears → navigate two sidebar destinations, asserting the route changes → log out (redirect to /login) → log back in and assert authenticated.

Selector + CI conventions

  • Roles/aria first. Inputs via getByPlaceholder; buttons via getByRole('button', { name }); nav by visible label text; log-out by its existing accessibilityLabel.
  • No hardcoded copy. Button/nav names come from the i18n catalog (packages/i18n/messages/en.json) through fixtures/strings.ts.
  • Minimal client changes (preserving existing a11y props):
    • apps/client/app/(app)/notes.tsx: one testID="note-row" on the list item (RN-Web → data-testid) so the created note is unambiguous; plus accessibilityRole="button" on the create button.
    • apps/client/app/login.tsx, register.tsx: accessibilityRole="button" on the submit buttons. Their visible label equals the screen heading, so without a button role they were ambiguous — and these primary actions genuinely should expose a button role to assistive tech (the OAuth/log-out buttons already do). An accessibility improvement, not test-only scaffolding.
  • CI: a new e2e job in .forgejo/workflows/pr.yml mirroring the existing scaffold (catthehacker image, the same SHA-pinned actions/checkout + actions/setup-node, corepack enable, pnpm install --frozen-lockfile --ignore-scripts, pnpm rebuild), then export:web + api build + playwright install --with-deps chromium + pnpm -F @carol/e2e test. SQLite + Chromium only. No artifact-upload step — no SHA-pinnable artifact action is used elsewhere in the repo, so none was invented.

No new runtime env var, so no README Configuration-table change.

Verification

  • pnpm -F @carol/e2e test — smoke spec passes headless Chromium (boots the app, all 6 steps green; verified across consecutive runs to confirm the per-boot DB wipe).
  • pnpm -F @carol/e2e exec tsc --noEmit — clean.
  • actionlint .forgejo/workflows/pr.yml — passes.
  • pnpm -F @carol/client lint + typecheck — clean (touched client files).

Follow-ups

  • #325 — per-domain critical-path coverage.
  • #326 — test-infra hardening (per-test DB reset + logged-in storageState fixture).
  • #327 — cross-browser (Firefox/WebKit) + mobile-viewport / drawer-nav.

🤖 Generated with Claude Code

## What Stands up Carol's first browser-level e2e: a new `@carol/e2e` workspace (`apps/e2e/`) with a Playwright harness that boots the **assembled** app and one smoke spec that drives it end to end. This is the foundation slice of #150 — broader coverage is split into child tickets. Closes #150 ## How the harness boots the app Playwright's `webServer` runs `scripts/serve.sh`, which is build-aware and idempotent: 1. `pnpm -F @carol/client export:web` if `apps/client/dist` is missing. 2. `pnpm -F @carol/api build` if `apps/api/.next` is missing. 3. `pnpm -F @carol/api start` — the Next.js API serving `/api/*` **and** the built Expo RN-Web SPA (`SPA_BUNDLE_PATH=apps/client/dist`) against a throwaway file-backed SQLite under `apps/e2e/.tmp/`, wiped on every boot. Readiness gates on `GET /api/health`; `reuseExistingServer` is on outside CI. Chromium + SQLite only; `retries: 2` and `trace: on-first-retry` in CI. A note on the DB: the plan called for `sqlite::memory:`, but `next start` applies migrations from the instrumentation hook, whose module graph is separate from the API route handlers in a production build — a libsql `:memory:` DB isn't shared across those two module instances, so requests hit an unmigrated (empty) DB ("no such table: users"). A throwaway **file** DB is shared by every connection in the process and still fresh per boot, so it preserves the plan's intent while actually working. Documented in `serve.sh` and the workspace README. ## Smoke flow (`tests/smoke.spec.ts`) One ordered spec: register a `uniqueEmail()` user (fresh DB ⇒ first user is admin, lands authenticated at `/notes`) → assert the sidebar renders → create a note and assert it appears → navigate two sidebar destinations, asserting the route changes → log out (redirect to `/login`) → log back in and assert authenticated. ## Selector + CI conventions - **Roles/aria first.** Inputs via `getByPlaceholder`; buttons via `getByRole('button', { name })`; nav by visible label text; log-out by its existing `accessibilityLabel`. - **No hardcoded copy.** Button/nav names come from the i18n catalog (`packages/i18n/messages/en.json`) through `fixtures/strings.ts`. - **Minimal client changes** (preserving existing a11y props): - `apps/client/app/(app)/notes.tsx`: one `testID="note-row"` on the list item (RN-Web → `data-testid`) so the created note is unambiguous; plus `accessibilityRole="button"` on the create button. - `apps/client/app/login.tsx`, `register.tsx`: `accessibilityRole="button"` on the submit buttons. Their visible label equals the screen heading, so without a button role they were ambiguous — and these primary actions genuinely should expose a button role to assistive tech (the OAuth/log-out buttons already do). An accessibility improvement, not test-only scaffolding. - **CI**: a new `e2e` job in `.forgejo/workflows/pr.yml` mirroring the existing scaffold (catthehacker image, the same SHA-pinned `actions/checkout` + `actions/setup-node`, `corepack enable`, `pnpm install --frozen-lockfile --ignore-scripts`, `pnpm rebuild`), then `export:web` + `api build` + `playwright install --with-deps chromium` + `pnpm -F @carol/e2e test`. SQLite + Chromium only. No artifact-upload step — no SHA-pinnable artifact action is used elsewhere in the repo, so none was invented. No new runtime env var, so no README Configuration-table change. ## Verification - `pnpm -F @carol/e2e test` — smoke spec **passes** headless Chromium (boots the app, all 6 steps green; verified across consecutive runs to confirm the per-boot DB wipe). - `pnpm -F @carol/e2e exec tsc --noEmit` — clean. - `actionlint .forgejo/workflows/pr.yml` — passes. - `pnpm -F @carol/client lint` + `typecheck` — clean (touched client files). ## Follow-ups - #325 — per-domain critical-path coverage. - #326 — test-infra hardening (per-test DB reset + logged-in `storageState` fixture). - #327 — cross-browser (Firefox/WebKit) + mobile-viewport / drawer-nav. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
test(e2e): add Playwright harness and smoke suite
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OpenAPI (pull_request) Successful in 1m55s
PR / Static analysis (pull_request) Failing after 1m43s
PR / Typecheck (pull_request) Successful in 3m26s
PR / Lint (pull_request) Successful in 3m32s
PR / Build (pull_request) Successful in 2m58s
PR / OSV-Scanner (pull_request) Successful in 1m19s
PR / pnpm audit (pull_request) Successful in 2m25s
PR / Package age policy (soft) (pull_request) Successful in 56s
Secrets / gitleaks (pull_request) Successful in 1m2s
PR / Test (sqlite) (pull_request) Successful in 2m55s
PR / Client (web export smoke) (pull_request) Successful in 3m51s
PR / Coverage (soft) (pull_request) Successful in 2m12s
PR / Test (postgres) (pull_request) Failing after 4m8s
PR / E2E (Playwright) (pull_request) Successful in 4m27s
PR / Trivy (image) (pull_request) Successful in 4m0s
65a31966f3
Stand up a new @carol/e2e workspace (apps/e2e) that boots the assembled
app via Playwright's webServer — the Next.js API serving /api/* plus the
built Expo RN-Web SPA against a throwaway file-backed SQLite — and gates
readiness on /api/health. One smoke spec drives the full stack: register
the first user (admin) -> land authenticated at /notes -> assert sidebar
-> create a note -> navigate two sidebar destinations -> log out -> log
back in.

Selectors are roles/aria first with i18n catalog values sourced from
packages/i18n via fixtures/strings.ts (no hardcoded copy). Minimal
client changes: a note-row testID and accessibilityRole="button" on the
login/register/notes primary action buttons (an a11y improvement that
also exposes them to getByRole). Adds an SQLite+Chromium e2e job to the
PR workflow, mirroring the existing job scaffold and SHA-pinned actions.

Refs #325, #326, #327

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 82.3% ≥ 50%
Branches 73.4% ⚠️ ≥ 75%
Functions 91.6% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 82.3% ✅ | ≥ 50% | | Branches | 73.4% ⚠️ | ≥ 75% | | Functions | 91.6% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
test(e2e): silence njsscan hardcoded-password finding on test fixture
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 4s
PR / Static analysis (pull_request) Successful in 2m21s
PR / OpenAPI (pull_request) Successful in 2m34s
PR / pnpm audit (pull_request) Successful in 2m48s
PR / Lint (pull_request) Successful in 3m19s
PR / Client (web export smoke) (pull_request) Successful in 3m45s
PR / OSV-Scanner (pull_request) Successful in 1m28s
PR / Test (postgres) (pull_request) Successful in 3m51s
PR / Typecheck (pull_request) Successful in 3m57s
PR / Test (sqlite) (pull_request) Successful in 4m5s
PR / Build (pull_request) Successful in 4m10s
PR / Package age policy (soft) (pull_request) Successful in 52s
Secrets / gitleaks (pull_request) Successful in 41s
PR / E2E (Playwright) (pull_request) Successful in 4m43s
PR / Trivy (image) (pull_request) Successful in 2m19s
PR / Coverage (soft) (pull_request) Successful in 2m5s
82b046860e
The e2e TEST_PASSWORD is a throwaway credential for ephemeral users in a
DB wiped each boot, but njsscan's hardcoded_secrets rule flagged it and
failed the static-analysis gate. Suppress with an inline nosemgrep plus
a justification, matching the repo's existing OAuth-route pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
james merged commit 29f466cf47 into main 2026-06-28 20:28:29 +00:00
james deleted branch test/e2e-playwright-foundation 2026-06-28 20:28:30 +00:00
Sign in to join this conversation.
No description provided.