Dev notes
Two classes of CI failure that look unrelated but share a root cause: the test environment is not your laptop. Auth0 crashing the entire middleware, and a search test depending on an external API that CI can't reach reliably. Each one required a different fix.
Auth0's Auth0Client is constructed at module load time in src/lib/auth0.ts. In production or local dev, the environment variables are set and the client initializes cleanly. In GitHub Actions, unset secrets resolve to empty strings, not undefined — so the fallback process.env.AUTH0_SECRET ?? '' was already empty. The SDK read the empty string, failed its own validation, and threw during initialization.
The subtle part: this is a module-level throw, not a request-level throw. Node.js caches module evaluation, so the first time auth0.ts is imported it runs and the error propagates up to proxy.ts, which also fails to initialize. Next.js middleware runs before every request, so every route — the TCG browse page, the landing page, everything — returned 500.
The fix is two-part. In ci.yml, each Auth0 env var gets an explicit placeholder fallback:
AUTH0_SECRET: ${{ secrets.AUTH0_SECRET || 'ci-placeholder-secret-32-characters!!' }}
AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN || 'placeholder.auth0.com' }}This gives the SDK something valid-looking to initialize against. The public E2E tests never hit a real Auth0 endpoint, so the placeholder values are never used at runtime — they just need to satisfy the startup validation. The second part is a try-catch around auth0.middleware(request) in proxy.ts so that even if Auth0 throws at runtime on an /auth/* request, it falls through to NextResponse.next() rather than crashing the middleware.
After the Auth0 fix, the public E2E suite nearly passed. The remaining failure was the TCG search test. The sequence of attempts is instructive.
Attempt 1 — waitForResponse: register a listener for the /api/tcg/cards?q=Pikachu response, fill the search input, await the listener. Race condition: if the app re-renders quickly enough that the fetch completes before the test sets up the listener, the promise never resolves.
Attempt 2 — expect.poll on DOM hrefs: instead of waiting for the network event, poll the card link elements until their hrefs differ from the initial unfiltered set. No race on listener setup. But a new problem: React Query clears the previous page while loading the new one, producing a brief state where the href array is empty. An empty array satisfies not.toEqual(initialHrefs), so the poll resolves immediately — then filteredHrefs.length > 0 fails because the Pikachu results haven't arrived yet. And in CI, where the TCGdex external API was unreachable within the 10-second timeout, those results never arrived.
Attempt 3 — page.route mock: intercept /api/tcg/cards* at the browser level. Requests with q=pikachu get a fixed mock payload instantly; everything else passes through. The poll assertion changes from not.toEqual(initialHrefs) to toContainEqual("/tcg/pokemon/card/base1-58"). A specific href that only exists in the mock response — the poll only resolves when the mock data is actually rendered, not on the transient empty state.
The mock was in place but the test still failed. The DOM still showed the unfiltered cards after searching for Pikachu. Adding request logging revealed the real problem: the /api/tcg/cards?q=Pikachu request was never made at all. The mock never had a chance to fire.
The culprit was in BrowseContent.tsx. The page is server-rendered, so it arrives with initialCards — the unfiltered first page. That prop was passed directly to useInfiniteQuery as initialData, unconditionally. React Query applies initialData to whatever the current query key is — including { q: 'Pikachu', type: '' }. When the search term changed, React Query created a new cache entry for the Pikachu key, immediately seeded it with the unfiltered server data, and then checked staleTime. The data had just been set — well within the 30-second window — so no fetch was triggered.
The fix: guard initialData behind !debouncedSearch && !type. The server data is only valid for the unfiltered view — the exact key it was fetched for. Any filtered key gets undefined, React Query finds nothing in the cache, and fetches immediately.
The initial page load in beforeEach still hits the real /api/tcg/cards route (which in turn hits TCGdex). This is intentional. Mocking the initial load would make the browse page axe scan and the scroll-to-sentinel test meaningless — they need real rendered content to be useful. The waitForSelector in beforeEach has a 15-second timeout and the CDN-cached initial response consistently arrives within that window.
The search test is different: it specifically tests that filtering works as a user interaction. The interesting behavior is the debounce, the URL update, and the DOM transition — not whether TCGdex happens to have Pikachu cards today. Mocking the search fetch tests the right thing and removes a variable that CI has no control over.
Both fixes follow the same principle: CI should not depend on external services behaving a specific way. Auth0 can fail to initialize because secrets aren't present — give the SDK valid-looking values and handle any runtime errors defensively. TCGdex can be slow or unreachable — mock the internal API route so the test controls the response.
The right boundary for E2E mocking is your own API surface. The Next.js routes at /api/* are part of this app. The TCGdex API is not. Mocking at the boundary between the two gives you full test control without hollowing out what the test actually exercises.