Web App UAT Skill
Real browser testing for web applications using Playwright. This skill captures EVERYTHING — console errors, network failures, rendering bugs, broken i18n keys, missing data — and fixes bugs as they're found.
Works with any web stack: React, Vue, Angular, Svelte, Next.js, Nuxt, Ionic/Capacitor, and plain HTML.
CRITICAL RULES
- Console errors are bugs. Every
console.error, unhandled rejection, and runtime exception MUST be captured and reported. If a console error blocks functionality, FIX IT before continuing. - Network failures are bugs. 401s, 500s, CORS errors, timeout responses — capture them ALL. Check if the backend is returning proper data or error payloads.
- Visual rendering = truth. Screenshots show what the user actually sees. If a component renders "---", "undefined", "NaN", "[object Object]", or a raw i18n key, that's a bug.
- Backend logs matter. Check server logs for errors that cause frontend skeleton loaders or empty states.
- Fix bugs inline. Don't just report — fix the code, verify the fix compiles, then re-test.
Prerequisites
- Playwright installed:
npx playwright --version(v1.40+) - Chromium browser:
npx playwright install chromiumif needed - Frontend running (default
http://localhost:3000— override withBASE_URL) - Backend running (default
http://localhost:4000— override withBACKEND_URL)
Getting Started
Before running UAT, the skill needs to understand your app. It will:
- Auto-detect your stack by reading
package.json, framework configs, and route definitions - Build a screen checklist from your routes/pages
- Identify auth strategy from your code (JWT, cookies, OAuth, etc.)
If your project has a uat.config.js in the root, the skill uses it directly. Otherwise, it auto-discovers screens and asks you to confirm.
UAT Config (Optional)
Create uat.config.js in your project root for repeatable runs:
module.exports = {
// Base URLs
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
backendUrl: process.env.BACKEND_URL || 'http://localhost:4000',
// Browser settings
viewport: { width: 1440, height: 900 },
colorScheme: 'dark', // 'dark' | 'light' | 'no-preference'
headless: true,
// Authentication (pick one)
auth: {
// Option A: Reuse saved browser state (cookies, localStorage)
storageState: '/tmp/uat-auth-state.json',
// Option B: Login programmatically
// login: async (page) => {
// await page.goto('/login');
// await page.fill('input[type="email"]', process.env.TEST_EMAIL);
// await page.fill('input[type="password"]', process.env.TEST_PASSWORD);
// await page.click('button[type="submit"]');
// await page.waitForURL('**/dashboard', { timeout: 15000 });
// },
// Option C: Open headed browser for manual login
// interactive: true,
},
// Health check endpoints (verified before UAT starts)
healthChecks: [
'/health',
// '/api/ping',
],
// Screens to test — each screen gets a full pass
screens: [
{
name: 'Home',
path: '/',
checks: [
'page loads without console errors',
'page title is set',
'main content renders (not empty/skeleton)',
],
},
{
name: 'Dashboard',
path: '/dashboard',
checks: [
'data renders with real values (not placeholders)',
'charts/graphs render (canvas/svg has dimensions > 0)',
'no failed API calls',
],
},
// Add your screens...
],
// Mobile viewport for responsive testing
mobileViewport: { width: 390, height: 844 },
// Screenshots directory
screenshotDir: '/tmp/uat-screenshots',
// i18n settings (set to null to skip i18n checks)
i18n: {
framework: 'auto', // 'i18next' | 'react-intl' | 'vue-i18n' | 'auto' | null
},
};
Authentication
Option A: Reuse Saved Session (recommended)
Run the login helper once in headed mode, then reuse the state:
// Save auth state after manual login
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(BASE_URL);
// ... manual login happens ...
await context.storageState({ path: '/tmp/uat-auth-state.json' });
Option B: Programmatic Login
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
await page.goto(`${BASE_URL}/login`);
await page.fill('input[type="email"]', process.env.TEST_EMAIL);
await page.fill('input[type="password"]', process.env.TEST_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard', { timeout: 15000 });
Option C: Interactive Login
# Opens a browser window for manual login, saves state
node assets/login-helper.js
UAT Script Pattern
Every UAT run follows this structure:
const { chromium } = require('playwright');
const {
setupErrorCapture, screenshot, waitForSettle,
checkBrokenI18n, checkA11y, checkEmptyData, printReport
} = require('./assets/test-helper');
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
async function run() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: '/tmp/uat-auth-state.json',
viewport: { width: 1440, height: 900 },
});
const page = await context.newPage();
const errors = setupErrorCapture(page);
// ═══ SCREEN 1: Navigate, settle, check, screenshot ═══
await page.goto(`${BASE_URL}/`);
await waitForSettle(page);
const a11y = await checkA11y(page);
const i18n = await checkBrokenI18n(page);
const empty = await checkEmptyData(page);
await screenshot(page, '01-home');
printReport('Home', {
'Page loads': true,
'No console errors': errors.console.length === 0,
'Single h1': a11y.h1Count === 1,
'Has <main>': a11y.hasMain,
'No broken i18n': i18n.length === 0,
'No empty data': empty.length === 0,
}, errors);
// ═══ REPEAT FOR EACH SCREEN ═══
// ═══ FINAL REPORT ═══
console.log('\n═══ UAT SUMMARY ═══');
console.log(`Console errors: ${errors.console.length}`);
errors.console.forEach(e => console.log(` ❌ [${e.url}] ${e.text.substring(0, 200)}`));
console.log(`Network errors: ${errors.network.length}`);
errors.network.forEach(e => console.log(` 🔴 HTTP ${e.status}: ${e.reqUrl}`));
console.log(`Page errors: ${errors.pageErrors.length}`);
console.log(`Warnings: ${errors.warnings.length}`);
await browser.close();
}
run().catch(err => {
console.error('UAT CRASHED:', err.message);
process.exit(1);
});
Screen Testing Methodology
For each screen in the checklist:
- Navigate —
await page.goto(url) - Settle —
await waitForSettle(page)(network idle + render delay) - Capture — screenshot the initial state
- Validate — run all checks:
checkA11y(page)— landmarks, headings, focus targetscheckBrokenI18n(page)— raw keys, unresolved placeholderscheckEmptyData(page)— placeholder values in data cells- Custom checks per screen (data loaded, charts rendered, etc.)
- Interact — test key user flows (click, type, navigate)
- Report —
printReport()with pass/fail per check
Universal Checks (Every Screen)
Accessibility (WCAG 2.2 AA)
- Tab through entire page — focus ring visible on every interactive element
- Exactly one
<h1>per page -
<main>or[role="main"]landmark present -
<nav>hasaria-label - All
<img>elements havealtattributes - No
<div onclick>— interactive elements must be<button>or<a> - Touch targets >= 44x44px (mobile)
- Color contrast meets 4.5:1 ratio
i18n / Localization
- No raw keys visible (e.g.,
KEY 'FOO.BAR',t('key'),$t('key')) - No unresolved
{{variable}}or{variable}placeholders - Date/number formatting matches locale
- Locale switch updates all visible text (if applicable)
Data Integrity
- No placeholder values: "---", "NaN", "undefined", "null", "[object Object]", "$0.00"
- Loading states resolve to real content (no infinite skeletons)
- Empty states are intentional (show a message, not blank space)
Responsive (Mobile Viewport)
- No horizontal scrollbar at 390px width
- Navigation is accessible (hamburger menu, tab bar, etc.)
- Text is readable without zooming
- Modals/dialogs fit within viewport
Performance
- Page settles within 5 seconds
- No infinite API polling (check network tab)
- No memory leaks from repeated navigation (console warnings)
Bug Triage
When a bug is found:
- Screenshot it —
await screenshot(page, 'BUG-description') - Capture console — log the exact error text and stack trace
- Identify root cause — read the source file, trace the data flow
- Classify severity:
- P0 BLOCKER: App won't load, screen completely broken, data loss risk
- P1 HIGH: Feature doesn't work, wrong data displayed, accessibility barrier
- P2 MEDIUM: Visual glitch, missing data that has a fallback, minor a11y issue
- P3 LOW: Cosmetic, console warning, edge case
- Fix P0/P1 immediately — edit the code, verify compilation, re-test
- Log P2/P3 — report in summary, fix after completing full screen pass
Backend Health Pre-Check
Before testing screens, verify the backend is alive:
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:4000';
async function checkBackendHealth(endpoints = ['/health']) {
console.log('═══ Backend Health ═══');
for (const ep of endpoints) {
try {
const res = await fetch(`${BACKEND_URL}${ep}`);
const status = res.status < 400 ? '✅' : '❌';
console.log(` ${status} ${ep}: HTTP ${res.status}`);
} catch (e) {
console.log(` ❌ ${ep}: UNREACHABLE — ${e.message}`);
}
}
}
Post-UAT Report
After completing all screens, generate a report with:
Per-screen scores (1-10) based on:
- Functionality: Does it work? (40%)
- Data accuracy: Are real values shown? (25%)
- Accessibility: Keyboard, screen reader, contrast (20%)
- Visual quality: Layout, spacing, responsive (15%)
All bugs found — severity, file, line, fix status
Overall health score — weighted average across all screens
Recommendations — prioritized list of fixes for next sprint
Framework-Specific Tips
React (CRA, Vite, Next.js)
- Wait for hydration:
waitForSettle(page, 2000)after navigation - Check for React error boundaries rendering fallback UI
- DevTools warnings about keys, deprecated lifecycle methods are worth logging
Vue (Nuxt, Vite)
v-ifcan cause flash of missing content — screenshot after settle- Check
$t()calls resolve (vue-i18n)
Angular
- Zone.js may keep network "busy" — use
waitForSettlewith longer timeout - Check for
ng-reflect-*attributes leaking into production builds
Ionic / Capacitor (Hybrid Mobile)
- Test with mobile viewport (390x844) as primary
ion-contentscrolling may differ from native scroll- Safe area insets: check content isn't hidden behind notch/home indicator
- Test
ion-modal,ion-action-sheetdismiss behaviors - Hardware back button simulation:
page.goBack()
Next.js / Nuxt (SSR)
- First paint may differ from hydrated state — screenshot both
- Check for hydration mismatch warnings in console
- API routes: test
/api/*endpoints in health check