Webapp-uat webapp-uat

Full browser UAT for web apps — Playwright testing with console/network error capture, accessibility checks, i18n validation, and bug triage. Use when running screen-by-screen UAT or testing specific features in any web or hybrid app (React, Vue, Angular, Ionic, Next.js, etc).

install
source · Clone the upstream repo
git clone https://github.com/tsilverberg/webapp-uat
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/tsilverberg/webapp-uat "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.agents/skills/webapp-uat" ~/.claude/skills/tsilverberg-webapp-uat-webapp-uat && rm -rf "$T"
manifest: .agents/skills/webapp-uat/SKILL.md
source content

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

  1. 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.
  2. Network failures are bugs. 401s, 500s, CORS errors, timeout responses — capture them ALL. Check if the backend is returning proper data or error payloads.
  3. 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.
  4. Backend logs matter. Check server logs for errors that cause frontend skeleton loaders or empty states.
  5. 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 chromium
    if needed
  • Frontend running (default
    http://localhost:3000
    — override with
    BASE_URL
    )
  • Backend running (default
    http://localhost:4000
    — override with
    BACKEND_URL
    )

Getting Started

Before running UAT, the skill needs to understand your app. It will:

  1. Auto-detect your stack by reading
    package.json
    , framework configs, and route definitions
  2. Build a screen checklist from your routes/pages
  3. 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:

  1. Navigate
    await page.goto(url)
  2. Settle
    await waitForSettle(page)
    (network idle + render delay)
  3. Capture — screenshot the initial state
  4. Validate — run all checks:
    • checkA11y(page)
      — landmarks, headings, focus targets
    • checkBrokenI18n(page)
      — raw keys, unresolved placeholders
    • checkEmptyData(page)
      — placeholder values in data cells
    • Custom checks per screen (data loaded, charts rendered, etc.)
  5. Interact — test key user flows (click, type, navigate)
  6. 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>
    has
    aria-label
  • All
    <img>
    elements have
    alt
    attributes
  • 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:

  1. Screenshot it
    await screenshot(page, 'BUG-description')
  2. Capture console — log the exact error text and stack trace
  3. Identify root cause — read the source file, trace the data flow
  4. 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
  5. Fix P0/P1 immediately — edit the code, verify compilation, re-test
  6. 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:

  1. 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%)
  2. All bugs found — severity, file, line, fix status

  3. Overall health score — weighted average across all screens

  4. 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-if
    can cause flash of missing content — screenshot after settle
  • Check
    $t()
    calls resolve (vue-i18n)

Angular

  • Zone.js may keep network "busy" — use
    waitForSettle
    with longer timeout
  • Check for
    ng-reflect-*
    attributes leaking into production builds

Ionic / Capacitor (Hybrid Mobile)

  • Test with mobile viewport (390x844) as primary
  • ion-content
    scrolling may differ from native scroll
  • Safe area insets: check content isn't hidden behind notch/home indicator
  • Test
    ion-modal
    ,
    ion-action-sheet
    dismiss 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