[{"data":1,"prerenderedAt":746},["ShallowReactive",2],{"navigation":3,"blog-2026-05-20-playwright-lessons":25},[4,8,12],{"title":5,"path":6,"stem":7},"Blog","\u002F","1.index",{"title":9,"path":10,"stem":11},"About","\u002Fabout","2.about",{"title":5,"path":13,"stem":14,"children":15,"page":24},"\u002Fblog","blog",[16,20],{"title":17,"path":18,"stem":19},"From SignUp Genius Frustration to a Purpose-Built Reservation App — With a Little Help from Claude Code","\u002Fblog\u002F2026-05-05-reserveit","blog\u002F2026-05-05-reserveIt",{"title":21,"path":22,"stem":23},"Getting Playwright to Play Nice with Nuxt and Vercel CI","\u002Fblog\u002F2026-05-20-playwright-lessons","blog\u002F2026-05-20-playwright-lessons",false,{"id":26,"title":21,"body":27,"category":732,"date":733,"description":734,"extension":735,"layout":736,"meta":737,"navigation":415,"path":22,"seo":738,"stem":23,"tags":739,"__hash__":745},"content\u002Fblog\u002F2026-05-20-playwright-lessons.md",{"type":28,"value":29,"toc":721},"minimark",[30,42,45,48,53,61,117,128,145,156,248,251,253,261,274,280,287,289,293,304,309,328,371,373,377,388,443,449,451,455,462,534,537,539,543,546,567,570,579,581,585,595,602,677,679,683,690,701,708,717],[31,32,33,34,41],"p",{},"Writing end-to-end tests for ",[35,36,40],"a",{"href":37,"rel":38},"https:\u002F\u002Fgithub.com\u002Fmelanieawilson\u002Freserve-it",[39],"nofollow","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.",[31,43,44],{},"This post covers what broke, why, and what actually fixed it.",[46,47],"hr",{},[49,50,52],"h2",{"id":51},"the-core-problem-ssr-hydration","The Core Problem: SSR Hydration",[31,54,55,56,60],{},"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 — ",[57,58,59],"em",{},"before"," Vue has finished attaching event handlers. The result is a test that looks correct but silently does nothing:",[62,63,68],"pre",{"className":64,"code":65,"language":66,"meta":67,"style":67},"language-ts shiki shiki-themes github-dark github-light","\u002F\u002F Finds the button, confirms visible and enabled, clicks... nothing happens\nawait page.getByRole('button', { name: 'Reserve' }).click()\n","ts","",[69,70,71,80],"code",{"__ignoreMap":67},[72,73,76],"span",{"class":74,"line":75},"line",1,[72,77,79],{"class":78},"sryI4","\u002F\u002F Finds the button, confirms visible and enabled, clicks... nothing happens\n",[72,81,83,87,91,95,98,102,105,108,111,114],{"class":74,"line":82},2,[72,84,86],{"class":85},"scx8i","await",[72,88,90],{"class":89},"sQ3_J"," page.",[72,92,94],{"class":93},"s-Z4r","getByRole",[72,96,97],{"class":89},"(",[72,99,101],{"class":100},"sg6BJ","'button'",[72,103,104],{"class":89},", { name: ",[72,106,107],{"class":100},"'Reserve'",[72,109,110],{"class":89}," }).",[72,112,113],{"class":93},"click",[72,115,116],{"class":89},"()\n",[31,118,119,120,123,124,127],{},"The standard Playwright guidance is to use ",[69,121,122],{},"waitForLoadState('load')"," or ",[69,125,126],{},"waitForLoadState('networkidle')"," before interacting. Neither was sufficient here.",[129,130,131,140],"ul",{},[132,133,134,136,137,139],"li",{},[69,135,122],{}," fires when scripts are parsed, but ",[57,138,59],{}," Vue finishes hydrating",[132,141,142,144],{},[69,143,126],{}," — waiting for 500ms with no network activity — worked locally, but more on why that falls apart in CI below",[31,146,147,148,151,152,155],{},"The reliable fix: Vue 3 sets a ",[69,149,150],{},"__vue_app__"," property on the Nuxt root element (",[69,153,154],{},"#__nuxt",") after full hydration. Polling for this property is framework-aware and network-independent:",[62,157,159],{"className":64,"code":158,"language":66,"meta":67,"style":67},"async function waitForHydration(page: Page) {\n  await page.waitForFunction(\n    () => !!(document.querySelector('#__nuxt') as any)?.__vue_app__\n  )\n}\n",[69,160,161,187,200,236,242],{"__ignoreMap":67},[72,162,163,166,169,172,174,178,181,184],{"class":74,"line":75},[72,164,165],{"class":85},"async",[72,167,168],{"class":85}," function",[72,170,171],{"class":93}," waitForHydration",[72,173,97],{"class":89},[72,175,177],{"class":176},"sFbx2","page",[72,179,180],{"class":85},":",[72,182,183],{"class":93}," Page",[72,185,186],{"class":89},") {\n",[72,188,189,192,194,197],{"class":74,"line":82},[72,190,191],{"class":85},"  await",[72,193,90],{"class":89},[72,195,196],{"class":93},"waitForFunction",[72,198,199],{"class":89},"(\n",[72,201,203,206,209,212,215,218,220,223,226,229,233],{"class":74,"line":202},3,[72,204,205],{"class":89},"    () ",[72,207,208],{"class":85},"=>",[72,210,211],{"class":85}," !!",[72,213,214],{"class":89},"(document.",[72,216,217],{"class":93},"querySelector",[72,219,97],{"class":89},[72,221,222],{"class":100},"'#__nuxt'",[72,224,225],{"class":89},") ",[72,227,228],{"class":85},"as",[72,230,232],{"class":231},"s0DvM"," any",[72,234,235],{"class":89},")?.__vue_app__\n",[72,237,239],{"class":74,"line":238},4,[72,240,241],{"class":89},"  )\n",[72,243,245],{"class":74,"line":244},5,[72,246,247],{"class":89},"}\n",[31,249,250],{},"Call this after every navigation before interacting with the page. It's the single most important fix in the entire test suite.",[46,252],{},[49,254,256,257,260],{"id":255},"why-networkidle-hung-in-ci","Why ",[69,258,259],{},"networkidle"," Hung in CI",[31,262,263,264,266,267,270,271,273],{},"Locally, ",[69,265,259],{}," was a reasonable proxy for hydration — Vue's ",[69,268,269],{},"onMounted"," data fetches would complete, the network would go quiet, and tests would pass. In CI, running against a Vercel preview URL, ",[69,272,259],{}," hung indefinitely and eventually timed out.",[31,275,276,277,279],{},"The reason: Vercel injects analytics and Speed Insights scripts that make continuous background requests. The network never goes idle. ",[69,278,259],{}," is fundamentally incompatible with Vercel-hosted deployments.",[31,281,282,283,286],{},"The ",[69,284,285],{},"waitForHydration"," function above solves this completely — it checks the DOM directly with no network dependency.",[46,288],{},[49,290,292],{"id":291},"vercel-deployment-protection-blocking-playwright","Vercel Deployment Protection Blocking Playwright",[31,294,295,296,298,299,303],{},"After switching to ",[69,297,285],{},", tests still hung in GitHub Actions. A different problem entirely: Vercel's ",[300,301,302],"strong",{},"Deployment Protection"," was intercepting Playwright's requests and serving an auth wall instead of the app.",[31,305,306],{},[300,307,308],{},"Fix:",[310,311,312,319,322],"ol",{},[132,313,314,315,318],{},"Enable ",[300,316,317],{},"Protection Bypass for Automation"," in the Vercel project settings and copy the generated secret",[132,320,321],{},"Add it as a GitHub repository secret",[132,323,324,325,180],{},"Pass it as a request header in ",[69,326,327],{},"playwright.config.ts",[62,329,331],{"className":64,"code":330,"language":66,"meta":67,"style":67},"extraHTTPHeaders: process.env.VERCEL_BYPASS_SECRET\n  ? { 'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_SECRET }\n  : {},\n",[69,332,333,344,363],{"__ignoreMap":67},[72,334,335,338,341],{"class":74,"line":75},[72,336,337],{"class":93},"extraHTTPHeaders",[72,339,340],{"class":89},": process.env.",[72,342,343],{"class":231},"VERCEL_BYPASS_SECRET\n",[72,345,346,349,352,355,357,360],{"class":74,"line":82},[72,347,348],{"class":85},"  ?",[72,350,351],{"class":89}," { ",[72,353,354],{"class":100},"'x-vercel-protection-bypass'",[72,356,340],{"class":89},[72,358,359],{"class":231},"VERCEL_BYPASS_SECRET",[72,361,362],{"class":89}," }\n",[72,364,365,368],{"class":74,"line":202},[72,366,367],{"class":85},"  :",[72,369,370],{"class":89}," {},\n",[46,372],{},[49,374,376],{"id":375},"triggering-tests-on-deployment-not-on-push","Triggering Tests on Deployment, Not on Push",[31,378,379,380,383,384,387],{},"One architectural decision worth calling out: the CI pipeline triggers on ",[69,381,382],{},"deployment_status",", not ",[69,385,386],{},"push",". This means every Vercel preview deployment — not just merges to main — gets tested against the real deployed URL:",[62,389,393],{"className":390,"code":391,"language":392,"meta":67,"style":67},"language-yaml shiki shiki-themes github-dark github-light","on:\n  deployment_status:\n\njobs:\n  test:\n    if: github.event.deployment_status.state == 'success'\n","yaml",[69,394,395,403,411,417,424,431],{"__ignoreMap":67},[72,396,397,400],{"class":74,"line":75},[72,398,399],{"class":231},"on",[72,401,402],{"class":89},":\n",[72,404,405,409],{"class":74,"line":82},[72,406,408],{"class":407},"sZkSk","  deployment_status",[72,410,402],{"class":89},[72,412,413],{"class":74,"line":202},[72,414,416],{"emptyLinePlaceholder":415},true,"\n",[72,418,419,422],{"class":74,"line":238},[72,420,421],{"class":407},"jobs",[72,423,402],{"class":89},[72,425,426,429],{"class":74,"line":244},[72,427,428],{"class":407},"  test",[72,430,402],{"class":89},[72,432,434,437,440],{"class":74,"line":433},6,[72,435,436],{"class":407},"    if",[72,438,439],{"class":89},": ",[72,441,442],{"class":100},"github.event.deployment_status.state == 'success'\n",[31,444,445,448],{},[69,446,447],{},"PLAYWRIGHT_BASE_URL"," is set to a unique per-deployment preview URL. Only Chromium is installed in CI to keep run times manageable.",[46,450],{},[49,452,454],{"id":453},"shadcn-vue-select-components-the-portal-problem","shadcn-vue Select Components: The Portal Problem",[31,456,457,458,461],{},"shadcn-vue's ",[69,459,460],{},"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:",[62,463,465],{"className":64,"code":464,"language":66,"meta":67,"style":67},"\u002F\u002F ❌ Fails — options are teleported outside the listbox element\nawait page.getByRole('listbox').getByText('2 Days').click()\n\n\u002F\u002F ✅ Works — searches the full page for the option by ARIA role\nawait page.getByRole('option', { name: '2 Days' }).click()\n",[69,466,467,472,502,506,511],{"__ignoreMap":67},[72,468,469],{"class":74,"line":75},[72,470,471],{"class":78},"\u002F\u002F ❌ Fails — options are teleported outside the listbox element\n",[72,473,474,476,478,480,482,485,488,491,493,496,498,500],{"class":74,"line":82},[72,475,86],{"class":85},[72,477,90],{"class":89},[72,479,94],{"class":93},[72,481,97],{"class":89},[72,483,484],{"class":100},"'listbox'",[72,486,487],{"class":89},").",[72,489,490],{"class":93},"getByText",[72,492,97],{"class":89},[72,494,495],{"class":100},"'2 Days'",[72,497,487],{"class":89},[72,499,113],{"class":93},[72,501,116],{"class":89},[72,503,504],{"class":74,"line":202},[72,505,416],{"emptyLinePlaceholder":415},[72,507,508],{"class":74,"line":238},[72,509,510],{"class":78},"\u002F\u002F ✅ Works — searches the full page for the option by ARIA role\n",[72,512,513,515,517,519,521,524,526,528,530,532],{"class":74,"line":244},[72,514,86],{"class":85},[72,516,90],{"class":89},[72,518,94],{"class":93},[72,520,97],{"class":89},[72,522,523],{"class":100},"'option'",[72,525,104],{"class":89},[72,527,495],{"class":100},[72,529,110],{"class":89},[72,531,113],{"class":93},[72,533,116],{"class":89},[31,535,536],{},"If a click on a shadcn-vue select option appears to do nothing, check whether you're scoping the locator too narrowly.",[46,538],{},[49,540,542],{"id":541},"semantic-locators-over-css-selectors","Semantic Locators Over CSS Selectors",[31,544,545],{},"Early in the project, Claude Code used CSS selectors like:",[62,547,549],{"className":64,"code":548,"language":66,"meta":67,"style":67},"page.locator('span.text-sm.font-medium.text-muted-foreground')\n",[69,550,551],{"__ignoreMap":67},[72,552,553,556,559,561,564],{"class":74,"line":75},[72,554,555],{"class":89},"page.",[72,557,558],{"class":93},"locator",[72,560,97],{"class":89},[72,562,563],{"class":100},"'span.text-sm.font-medium.text-muted-foreground'",[72,565,566],{"class":89},")\n",[31,568,569],{},"These are brittle. Any styling change — a Tailwind class rename, a component refactor — silently breaks the test with no indication of what went wrong.",[31,571,572,573,578],{},"I had to nudge code to Playwright's documentation for the ",[35,574,577],{"href":575,"rel":576},"https:\u002F\u002Fplaywright.dev\u002Fdocs\u002Flocators",[39],"preferred locators",".",[46,580],{},[49,582,584],{"id":583},"vue-router-navigation-not-triggering-playwright-events","Vue Router Navigation Not Triggering Playwright Events",[31,586,587,590,591,594],{},[69,588,589],{},"router.replace()"," inside a Vue watcher doesn't always trigger Playwright's built-in navigation event system. ",[69,592,593],{},"page.waitForURL()"," timed out consistently in these cases.",[31,596,597,598,601],{},"The workaround is to poll ",[69,599,600],{},"window.location.href"," directly:",[62,603,605],{"className":64,"code":604,"language":66,"meta":67,"style":67},"\u002F\u002F ❌ Can time out when navigation is triggered by a Vue watcher\nawait page.waitForURL(\u002Fweek=\u002F)\n\n\u002F\u002F ✅ Polls the browser location directly\nawait page.waitForFunction(\n  () => window.location.href.includes('week=')\n)\n",[69,606,607,612,633,637,642,652,672],{"__ignoreMap":67},[72,608,609],{"class":74,"line":75},[72,610,611],{"class":78},"\u002F\u002F ❌ Can time out when navigation is triggered by a Vue watcher\n",[72,613,614,616,618,621,623,625,629,631],{"class":74,"line":82},[72,615,86],{"class":85},[72,617,90],{"class":89},[72,619,620],{"class":93},"waitForURL",[72,622,97],{"class":89},[72,624,6],{"class":100},[72,626,628],{"class":627},"seKLv","week=",[72,630,6],{"class":100},[72,632,566],{"class":89},[72,634,635],{"class":74,"line":202},[72,636,416],{"emptyLinePlaceholder":415},[72,638,639],{"class":74,"line":238},[72,640,641],{"class":78},"\u002F\u002F ✅ Polls the browser location directly\n",[72,643,644,646,648,650],{"class":74,"line":244},[72,645,86],{"class":85},[72,647,90],{"class":89},[72,649,196],{"class":93},[72,651,199],{"class":89},[72,653,654,657,659,662,665,667,670],{"class":74,"line":433},[72,655,656],{"class":89},"  () ",[72,658,208],{"class":85},[72,660,661],{"class":89}," window.location.href.",[72,663,664],{"class":93},"includes",[72,666,97],{"class":89},[72,668,669],{"class":100},"'week='",[72,671,566],{"class":89},[72,673,675],{"class":74,"line":674},7,[72,676,566],{"class":89},[46,678],{},[49,680,682],{"id":681},"the-pattern-that-emerges","The Pattern That Emerges",[31,684,685,686,689],{},"Looking back, most of these problems share a root cause: ",[300,687,688],{},"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.",[31,691,692,693,696,697,700],{},"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 ",[57,694,695],{},"visible"," but not yet ",[57,698,699],{},"ready",", and look for a framework-level signal — not a network or timing heuristic — to wait on.",[31,702,703,704,707],{},"Claude Code was useful throughout this process for generating test scaffolding and iterating quickly on locator strategies, but the diagnostic work — figuring out ",[57,705,706],{},"why"," something was failing — required reading Nuxt internals, Vercel docs, and Playwright source behavior carefully.",[31,709,710,711,716],{},"The full test suite is in the ",[35,712,715],{"href":713,"rel":714},"https:\u002F\u002Fgithub.com\u002Fmelanieawilson\u002Freserve-it\u002Ftree\u002F3a68a86e1a61f05402faa577138a74160aa32ff2\u002Ftests",[39],"ReserveIt repo"," if you want to see how it all fits together.",[718,719,720],"style",{},"html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .sZkSk, html code.shiki .sZkSk{--shiki-dark:#85E89D;--shiki-default:#22863A}html pre.shiki code .seKLv, html code.shiki .seKLv{--shiki-dark:#DBEDFF;--shiki-default:#032F62}",{"title":67,"searchDepth":82,"depth":82,"links":722},[723,724,726,727,728,729,730,731],{"id":51,"depth":82,"text":52},{"id":255,"depth":82,"text":725},"Why networkidle Hung in CI",{"id":291,"depth":82,"text":292},{"id":375,"depth":82,"text":376},{"id":453,"depth":82,"text":454},{"id":541,"depth":82,"text":542},{"id":583,"depth":82,"text":584},{"id":681,"depth":82,"text":682},"tech","2026-05-20","A layer-by-layer account of the SSR hydration, networkidle, and deployment protection problems I hit writing end-to-end tests for ReserveIt — and how I fixed them.","md",null,{},{"title":21,"description":734},[740,741,742,743,744],"Playwright","Nuxt","testing","CI\u002FCD","Vercel","fKXjqgEWkPF5Yr9q7ajFFNU87MK4JeqWJrFOrFgyxUw",1779571619450]