skills/qa-automation-specialist/SKILL.md
Production validation specialist for post-deployment smoke tests, SEO audits, visual regression, and analytics verification. Validates that deployed features meet acceptance criteria in the real environment, not just in CI.
npx skillsauth add curiositech/windags-skills qa-automation-specialistInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Production validation specialist. Designs and executes smoke tests, SEO audits, visual regression checks, redirect verification, and analytics event validation against live deployments.
Use this skill when:
Do NOT use this skill for:
Design smoke tests that validate critical user journeys after deployment:
// smoke-test.spec.ts — Playwright-based production smoke suite
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.SMOKE_URL || 'https://yoursite.com';
test.describe('Production Smoke Tests', () => {
test('homepage loads with correct status and critical elements', async ({ page }) => {
const response = await page.goto(BASE_URL);
expect(response?.status()).toBe(200);
// Core content renders
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('nav')).toBeVisible();
await expect(page.locator('footer')).toBeVisible();
// No console errors
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.reload();
await page.waitForLoadState('networkidle');
expect(errors.filter(e => !e.includes('favicon'))).toHaveLength(0);
});
test('critical API endpoints respond', async ({ request }) => {
const endpoints = ['/api/health', '/api/status'];
for (const endpoint of endpoints) {
const resp = await request.get(`${BASE_URL}${endpoint}`);
expect(resp.ok(), `${endpoint} returned ${resp.status()}`).toBeTruthy();
}
});
test('authentication flow works end-to-end', async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
// Use a dedicated smoke-test account, never production credentials
await page.getByLabel('Email').fill(process.env.SMOKE_USER!);
await page.getByLabel('Password').fill(process.env.SMOKE_PASS!);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/dashboard/);
});
});
Smoke test design principles:
#!/bin/bash
# validate-redirects.sh — Check redirect chains and broken links
set -euo pipefail
SITE_URL="${1:?Usage: validate-redirects.sh <base-url>}"
ERRORS=0
# Check redirect chains (max 2 hops allowed)
check_redirect() {
local url="$1"
local expected_final="$2"
local hops
hops=$(curl -sL -o /dev/null -w '%{num_redirects}' "$url")
local final
final=$(curl -sL -o /dev/null -w '%{url_effective}' "$url")
if [ "$hops" -gt 2 ]; then
echo "WARN: $url has $hops redirects (max 2)"
((ERRORS++))
fi
if [ "$final" != "$expected_final" ]; then
echo "FAIL: $url -> $final (expected $expected_final)"
((ERRORS++))
fi
}
# Check for broken internal links
check_links() {
local url="$1"
# Extract href values, filter to same-origin, check each
curl -s "$url" | grep -oP 'href="\K[^"]+' | grep "^/" | sort -u | while read -r path; do
local status
status=$(curl -s -o /dev/null -w '%{http_code}' "${SITE_URL}${path}")
if [ "$status" -ge 400 ]; then
echo "BROKEN: ${path} -> HTTP ${status}"
((ERRORS++))
fi
done
}
# Check asset loading (CSS, JS, images)
check_assets() {
local url="$1"
curl -s "$url" | grep -oP '(src|href)="\K[^"]+' | grep -E '\.(css|js|png|jpg|svg|woff2?)' | while read -r asset; do
local full_url
[[ "$asset" == http* ]] && full_url="$asset" || full_url="${SITE_URL}${asset}"
local status
status=$(curl -s -o /dev/null -w '%{http_code}' "$full_url")
if [ "$status" -ge 400 ]; then
echo "MISSING ASSET: ${asset} -> HTTP ${status}"
((ERRORS++))
fi
done
}
echo "=== Redirect Validation ==="
check_redirect "${SITE_URL}/old-path" "${SITE_URL}/new-path"
echo "=== Link Validation ==="
check_links "${SITE_URL}"
echo "=== Asset Validation ==="
check_assets "${SITE_URL}"
exit $ERRORS
// seo-audit.spec.ts
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.SMOKE_URL || 'https://yoursite.com';
async function auditSEO(page: Page, url: string) {
await page.goto(url);
// Title tag
const title = await page.title();
expect(title.length, 'Title should be 30-60 chars').toBeGreaterThanOrEqual(30);
expect(title.length, 'Title should be 30-60 chars').toBeLessThanOrEqual(60);
// Meta description
const metaDesc = await page.getAttribute('meta[name="description"]', 'content');
expect(metaDesc, 'Meta description must exist').toBeTruthy();
expect(metaDesc!.length, 'Meta desc should be 120-160 chars').toBeGreaterThanOrEqual(120);
expect(metaDesc!.length, 'Meta desc should be 120-160 chars').toBeLessThanOrEqual(160);
// Open Graph
const ogTitle = await page.getAttribute('meta[property="og:title"]', 'content');
const ogDesc = await page.getAttribute('meta[property="og:description"]', 'content');
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
expect(ogTitle, 'og:title must exist').toBeTruthy();
expect(ogDesc, 'og:description must exist').toBeTruthy();
expect(ogImage, 'og:image must exist').toBeTruthy();
// Canonical URL
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical, 'Canonical URL must exist').toBeTruthy();
expect(canonical, 'Canonical must be absolute').toMatch(/^https?:\/\//);
// Heading hierarchy
const h1Count = await page.locator('h1').count();
expect(h1Count, 'Exactly one H1 per page').toBe(1);
// Image alt text
const imagesWithoutAlt = await page.locator('img:not([alt])').count();
expect(imagesWithoutAlt, 'All images must have alt text').toBe(0);
return { title, metaDesc, ogTitle, canonical };
}
test.describe('SEO Audit', () => {
test('homepage SEO elements', async ({ page }) => {
await auditSEO(page, BASE_URL);
});
test('sitemap.xml is valid', async ({ request }) => {
const resp = await request.get(`${BASE_URL}/sitemap.xml`);
expect(resp.ok()).toBeTruthy();
const body = await resp.text();
expect(body).toContain('<urlset');
expect(body).toContain('<loc>');
// Every loc should be reachable
const locs = body.match(/<loc>([^<]+)<\/loc>/g) || [];
expect(locs.length, 'Sitemap should have entries').toBeGreaterThan(0);
});
test('robots.txt is valid', async ({ request }) => {
const resp = await request.get(`${BASE_URL}/robots.txt`);
expect(resp.ok()).toBeTruthy();
const body = await resp.text();
expect(body).toContain('User-agent:');
expect(body).toContain('Sitemap:');
// Should not disallow critical paths
expect(body).not.toMatch(/Disallow: \/$/m);
});
test('structured data is valid JSON-LD', async ({ page }) => {
await page.goto(BASE_URL);
const jsonLd = await page.locator('script[type="application/ld+json"]').allTextContents();
expect(jsonLd.length, 'At least one JSON-LD block').toBeGreaterThan(0);
for (const block of jsonLd) {
const parsed = JSON.parse(block);
expect(parsed['@context']).toContain('schema.org');
expect(parsed['@type']).toBeTruthy();
}
});
});
// visual-regression.spec.ts
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.SMOKE_URL || 'https://yoursite.com';
// Pages to capture for visual regression
const PAGES = [
{ name: 'homepage', path: '/' },
{ name: 'pricing', path: '/pricing' },
{ name: 'blog-index', path: '/blog' },
{ name: 'docs', path: '/docs' },
];
const VIEWPORTS = [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 375, height: 812 },
];
for (const pageConfig of PAGES) {
for (const viewport of VIEWPORTS) {
test(`visual: ${pageConfig.name} @ ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto(`${BASE_URL}${pageConfig.path}`);
await page.waitForLoadState('networkidle');
// Hide dynamic content that changes between runs
await page.evaluate(() => {
document.querySelectorAll('[data-testid="timestamp"], .live-counter')
.forEach(el => (el as HTMLElement).style.visibility = 'hidden');
});
await expect(page).toHaveScreenshot(
`${pageConfig.name}-${viewport.name}.png`,
{ maxDiffPixelRatio: 0.01, fullPage: true }
);
});
}
}
Visual regression workflow:
// analytics-validation.spec.ts
import { test, expect, Page } from '@playwright/test';
const BASE_URL = process.env.SMOKE_URL || 'https://yoursite.com';
interface AnalyticsEvent {
event: string;
properties: Record<string, unknown>;
}
async function captureAnalyticsEvents(page: Page): Promise<AnalyticsEvent[]> {
const events: AnalyticsEvent[] = [];
// Intercept PostHog capture calls
await page.route('**/e/?ip=1', async (route) => {
const postData = route.request().postDataJSON();
if (postData?.batch) {
for (const item of postData.batch) {
events.push({ event: item.event, properties: item.properties });
}
}
await route.fulfill({ status: 200, body: '1' });
});
return events;
}
test.describe('Analytics Event Validation', () => {
test('page view fires on navigation', async ({ page }) => {
const events = await captureAnalyticsEvents(page);
await page.goto(BASE_URL);
await page.waitForTimeout(2000); // Allow batch to flush
const pageViews = events.filter(e => e.event === '$pageview');
expect(pageViews.length).toBeGreaterThanOrEqual(1);
expect(pageViews[0].properties).toHaveProperty('$current_url');
});
test('CTA click fires custom event', async ({ page }) => {
const events = await captureAnalyticsEvents(page);
await page.goto(BASE_URL);
await page.getByRole('link', { name: /get started/i }).click();
await page.waitForTimeout(2000);
const ctaEvents = events.filter(e => e.event === 'cta_clicked');
expect(ctaEvents.length).toBeGreaterThanOrEqual(1);
expect(ctaEvents[0].properties).toHaveProperty('cta_location');
});
test('no duplicate event IDs in a single session', async ({ page }) => {
const events = await captureAnalyticsEvents(page);
await page.goto(BASE_URL);
await page.goto(`${BASE_URL}/pricing`);
await page.goto(`${BASE_URL}/blog`);
await page.waitForTimeout(2000);
const ids = events.map(e => e.properties?.['$insert_id']).filter(Boolean);
const uniqueIds = new Set(ids);
expect(uniqueIds.size, 'No duplicate event IDs').toBe(ids.length);
});
});
| Signal | Action | |--------|--------| | Hotfix deploy (1-2 files changed) | Smoke tests only (critical paths) | | Feature deploy (new page/route) | Smoke + SEO audit for new routes | | CSS/design system change | Smoke + full visual regression | | Analytics config change | Smoke + analytics event validation | | Infrastructure change (CDN, DNS) | Smoke + redirect chain + asset loading | | Full release | Everything |
Symptom: Smoke tests hit a staging environment and call it "production validation" Why wrong: Staging hides DNS issues, CDN caching, environment variable differences, and database state Fix: Run smoke tests against the actual production URL, with a dedicated smoke-test account
Symptom: Tests break on every deploy because they target CSS classes or DOM structure
Fix: Use accessible selectors (getByRole, getByLabel, data-testid) that survive refactors
Symptom: Visual regression tests fail on every run due to timestamps, avatars, or ad content
Fix: Mask dynamic regions before screenshot capture. Use data-testid to identify volatile elements.
Symptom: "I'll check PostHog tomorrow to see if events came through" Fix: Intercept network requests in tests and validate event shape and presence in real time
Symptom: A single 500-line test file that tests everything and takes 10 minutes Fix: Separate by concern (smoke, SEO, visual, analytics). Run them in parallel. Gate on smoke, alert on the rest.
Symptom: const url = 'https://mysite.com' scattered through test files
Fix: Environment variables for everything. Never commit credentials. Use a .env.smoke file.
Symptom: Tests pass but users see stale content because cache headers are wrong
Fix: Validate Cache-Control, ETag, and Last-Modified headers on critical assets
data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.