← Home

Getting Playwright to Play Nice with Nuxt and Vercel CI

in tech#Playwright#Nuxt#testing#CI/CD#Vercel

Writing end-to-end tests for ReserveIt turned out to be the most technically demanding part of the whole project. The app itself — a Nuxt 3 frontend backed by Supabase — came together quickly with Claude Code. The Playwright test suite took longer, not because the tests were complicated, but because getting them to run reliably against a deployed Vercel URL required peeling back several layers of problems, each one hiding behind the last.

This post covers what broke, why, and what actually fixed it.


The Core Problem: SSR Hydration

Nuxt renders HTML on the server and sends it to the browser. That means elements are visible in the DOM — and technically "enabled" from Playwright's perspective — before Vue has finished attaching event handlers. The result is a test that looks correct but silently does nothing:

// Finds the button, confirms visible and enabled, clicks... nothing happens
await page.getByRole('button', { name: 'Reserve' }).click()

The standard Playwright guidance is to use waitForLoadState('load') or waitForLoadState('networkidle') before interacting. Neither was sufficient here.

  • waitForLoadState('load') fires when scripts are parsed, but before Vue finishes hydrating
  • waitForLoadState('networkidle') — waiting for 500ms with no network activity — worked locally, but more on why that falls apart in CI below

The reliable fix: Vue 3 sets a __vue_app__ property on the Nuxt root element (#__nuxt) after full hydration. Polling for this property is framework-aware and network-independent:

async function waitForHydration(page: Page) {
  await page.waitForFunction(
    () => !!(document.querySelector('#__nuxt') as any)?.__vue_app__
  )
}

Call this after every navigation before interacting with the page. It's the single most important fix in the entire test suite.


Why networkidle Hung in CI

Locally, networkidle was a reasonable proxy for hydration — Vue's onMounted data fetches would complete, the network would go quiet, and tests would pass. In CI, running against a Vercel preview URL, networkidle hung indefinitely and eventually timed out.

The reason: Vercel injects analytics and Speed Insights scripts that make continuous background requests. The network never goes idle. networkidle is fundamentally incompatible with Vercel-hosted deployments.

The waitForHydration function above solves this completely — it checks the DOM directly with no network dependency.


Vercel Deployment Protection Blocking Playwright

After switching to waitForHydration, tests still hung in GitHub Actions. A different problem entirely: Vercel's Deployment Protection was intercepting Playwright's requests and serving an auth wall instead of the app.

Fix:

  1. Enable Protection Bypass for Automation in the Vercel project settings and copy the generated secret
  2. Add it as a GitHub repository secret
  3. Pass it as a request header in playwright.config.ts:
extraHTTPHeaders: process.env.VERCEL_BYPASS_SECRET
  ? { 'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_SECRET }
  : {},

Triggering Tests on Deployment, Not on Push

One architectural decision worth calling out: the CI pipeline triggers on deployment_status, not push. This means every Vercel preview deployment — not just merges to main — gets tested against the real deployed URL:

on:
  deployment_status:

jobs:
  test:
    if: github.event.deployment_status.state == 'success'

PLAYWRIGHT_BASE_URL is set to a unique per-deployment preview URL. Only Chromium is installed in CI to keep run times manageable.


shadcn-vue Select Components: The Portal Problem

shadcn-vue's Select component (built on reka-ui) teleports its dropdown options into a DOM portal outside the component tree. This means scoping locators to the select trigger doesn't work — the options simply aren't inside it:

// ❌ Fails — options are teleported outside the listbox element
await page.getByRole('listbox').getByText('2 Days').click()

// ✅ Works — searches the full page for the option by ARIA role
await page.getByRole('option', { name: '2 Days' }).click()

If a click on a shadcn-vue select option appears to do nothing, check whether you're scoping the locator too narrowly.


Semantic Locators Over CSS Selectors

Early in the project, Claude Code used CSS selectors like:

page.locator('span.text-sm.font-medium.text-muted-foreground')

These are brittle. Any styling change — a Tailwind class rename, a component refactor — silently breaks the test with no indication of what went wrong.

I had to nudge code to Playwright's documentation for the preferred locators.


Vue Router Navigation Not Triggering Playwright Events

router.replace() inside a Vue watcher doesn't always trigger Playwright's built-in navigation event system. page.waitForURL() timed out consistently in these cases.

The workaround is to poll window.location.href directly:

// ❌ Can time out when navigation is triggered by a Vue watcher
await page.waitForURL(/week=/)

// ✅ Polls the browser location directly
await page.waitForFunction(
  () => window.location.href.includes('week=')
)

The Pattern That Emerges

Looking back, most of these problems share a root cause: the gap between what Playwright can observe and what the framework has actually finished doing. SSR hydration, network activity, DOM portals, and client-side router events all fall into this category.

The general principle: when a Playwright interaction silently fails or a wait condition times out, don't assume the locator is wrong. Ask whether the element is visible but not yet ready, and look for a framework-level signal — not a network or timing heuristic — to wait on.

Claude Code was useful throughout this process for generating test scaffolding and iterating quickly on locator strategies, but the diagnostic work — figuring out why something was failing — required reading Nuxt internals, Vercel docs, and Playwright source behavior carefully.

The full test suite is in the ReserveIt repo if you want to see how it all fits together.