This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
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.
Update — August 30, 2026
The section above draws the line at the test boundary: our routes are ours, TCGdex is not, so mock at the edge. That holds for tests and says nothing about the build, which is where this went wrong.
Two nights red, with no test failure in it:
[WebServer] Export encountered an error on /tcg/pokemon/sets/[setId]/page: /tcg/pokemon/sets/me02, exiting the build. Error: Process from config.webServer was not able to start. Exit code: 1
The build exited 1, so Playwright never got a server. Every mock in the suite was irrelevant, because none of this happened inside a test.
The failing set was newly announced, and the page read set.serie.name, set.cardCount.official and set.legal.* without guards. I reproduced real crashes for that shape, fixed them, and shipped it — while writing in the pull request that I could not confirm this was the cause, because the API is unreachable from where I work and my local build never walked the failing path. That caveat turned out to be the important sentence.
It failed again a day later on a different set, on a branch that already carried the fix. So I removed the build-time fetch instead: pre-rendering ten sets meant ten chances for a third party to end the export. The failure moved one page over, to the sets list. That felt like progress and was still not the answer.
Vercel's build log said what GitHub's never did:
Failed to build /tcg/pokemon/sets/page (attempt 1 of 3) because it took more than 60 seconds. Retrying... Failed to build /tcg/pokemon/sets/page (attempt 2 of 3) because it took more than 60 seconds. Retrying... Failed to build /tcg/pokemon/sets/page after 3 attempts.
A timeout, not a crash. That page lists every series and then fetches each one, so its build cost is a multiple of however slow TCGdex is that minute. Nothing was malformed and no field was missing. It also explains the property that made this so slippery: it was intermittent and a re-run often cleared it, because the failure tracked upstream latency rather than anything in the diff. It reads as flake, and flake gets dismissed.
The fix is a twenty-second budget on the fan-out, and eventually mirroring the catalog into our own database so the pages stop asking at render time at all. From the build's point of view “unreachable” and “too slow” are the same thing, and the page had only ever handled the first.
I had two plausible mechanisms, evidence for both, and tests proving I had fixed something real each time. None of that made either one the cause. The line that settled it existed the whole time, in a different provider's console, and I did not go looking because a failure I already had an explanation for looked explained.