Dev notes
Playwright covering three user flows — auth redirects, TCG card browsing, and calendar CRUD — with axe-core accessibility scans wired into every public route test and a dedicated test calendar that gets created and deleted automatically around every run.
The calendar user flows are hard to unit test meaningfully. Creating an event involves clicking a time slot, filling a modal, saving, and confirming the result appears in the grid — across a server component that fetches events, a client component that manages modal state, a React Query mutation, and an API route. You can test each piece in isolation, but the interesting failures happen at the seams. The same is true for the auth redirect: the middleware cookie check, the redirect URL construction, and the Auth0 handoff are all trivially unit-testable, and none of those tests would catch a misconfigured returnTo param dropping users on the landing page after login.
playwright.config.ts to wire everything upwebServer in the config starts next dev and waits for http://localhost:3000 before any test runs. Locally it reuses an existing server if one is already running; CI always starts freshThe hardest part of E2E testing an Auth0-protected app is getting a valid session without doing a real OAuth dance on every test. The approach here: a globalSetup script that runs once before all tests. It launches a headless browser, navigates to /auth/login, fills in test credentials against the real Auth0 Universal Login page, and waits until the URL is back on the app domain. At that point the session cookie exists and the context is saved to e2e/.auth/user.json via context.storageState(). The authenticated Playwright project loads that file as its starting state, so every calendar test begins already logged in with no additional overhead.
Test data isolation uses a dedicated test calendar. After saving the session, globalSetup calls POST /api/calendar/calendars with the auth context to create a calendar named [E2E] Test Calendar and saves its ID to e2e/.auth/test-state.json. A matching globalTeardown deletes that calendar after the suite finishes. No test data leaks into the real account.
When E2E_TEST_EMAIL and E2E_TEST_PASSWORD are absent, globalSetup writes an empty storageState file so the config stays valid, and the calendar tests self-skip with test.skip. The public tests still run.
/calendar, /vitals, and /settings as an unauthenticated user and asserts that each one redirects to the Auth0 login page. No credentials needed, just confirming the middleware fires. The three tests are generated from a single array so adding a new protected route means adding one string/tcg/pokemon page: an axe scan of the browse page itself, searching for a card name filters the grid, scrolling the sentinel triggers the next page, and a separate test navigates to the first card's detail URL and runs a second axe scan thereEach public-route test runs an axe-core scan after the page settles. The shared checkA11y() helper in e2e/helpers/axe.ts runs AxeBuilder with wcag2a, wcag2aa, and wcag21aa tags, then fails the test with a diff of every violation — impact level, rule ID, and the first 120 characters of the offending element's HTML. The test output tells you exactly what to fix without having to reproduce the scan locally.
Running axe revealed four real violations. A landing page heading was missing text-white that every other section heading has — axe computed the dark theme foreground color against the page's root white background (not the dark section overlay, which is a sibling div, not an ancestor) and flagged the mismatch. A scrollable table in the NBA section was missing tabIndex=0 so keyboard users couldn't reach its content. The type badge colors across the TCG browser and card detail page used light /300 palette text on semi-transparent backgrounds — borderline against a pure white page. Switching to solid -100 backgrounds with -900 text (and dark mode counterparts) makes them definitively compliant and looks cleaner than the translucent approach anyway.
One fix was in the root layout itself: suppressHydrationWarning on the <html> element. The anti-FOUC script runs in the browser and writes data-theme="dark" to the element before React hydrates. React sees a mismatch between the server-rendered HTML (no attribute) and the DOM (attribute set by the script) and logs a warning. suppressHydrationWarning tells React the element intentionally differs — the right fix for any element touched by an inline script.
A dedicated e2e-accessibility job in CI runs after the quality checks pass, installs Chromium, and runs playwright test --project=public. A PR that introduces a missing label, a contrast failure, or any other WCAG 2.1 AA violation blocks the merge.
The TCG page uses an IntersectionObserver with a sentinel div at the bottom of the card grid. When the sentinel enters the viewport, fetchNextPage fires. Playwright can't trigger IntersectionObserver events directly, but scrollIntoViewIfNeeded() on the sentinel element does the same thing the browser does naturally — it scrolls the element into view and lets the observer fire on its own. The test then waits for the card count to increase. No mocks, no synthetic events, no polling — just the real mechanism at real speed.
The authenticated tests hadn't been run in a while, and three issues surfaced that had nothing to do with the features under test.
/continue/i. Auth0's Universal Login page also has a "Continue with Google" social button that matched the same regex. Every run clicked Google OAuth instead of the database login, landing on accounts.google.com instead of the app. The fix was tightening the regex to /^continue$/i so it only matches the exact button text, and using a dedicated Auth0 database user instead of a Google-linked account.{ id: string } but the backend returns { calendar: { id: string } }. The calendar ID was undefined, so every CRUD test that depended on it silently failed. Matching the type assertion to the actual schema fixed it.opacity-25 which dropped below the 4.5:1 contrast ratio, and the infinite scroll container was a scrollable region without keyboard access ( tabIndex and role="region"). The settings page test had a brittle locator that matched both <main> and <h1> — fixed by targeting the heading by role and name.Before this, the only automated check on a full user flow was the unit tests on individual functions. Those tests are valuable but they can't catch a broken modal interaction, a missing query invalidation after a mutation, or a redirect that lands on the wrong page. The E2E suite catches those. It also serves as living documentation — reading the calendar spec tells you exactly what the create and delete flows look like step by step, in a way that no README can stay in sync with.
The accessibility layer catches a different class of bug entirely — the kind that passes every functional test but breaks the experience for keyboard users or screen reader users. Contrast failures, missing landmark roles, inaccessible scrollable regions: none of those would ever surface in a unit test. Running axe in the same Playwright pass means accessibility is automatically re-checked on every public-route test run, not just when someone manually audits the page.
The public suite runs without credentials, so it can run in CI on every pull request with no secrets required. The auth suite is gated on the presence of test credentials and designed to skip cleanly when they're absent, so the CI pipeline stays green in both configurations.