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
tools
Building resilient distributed systems with circuit breakers, retries with full-jitter exponential backoff, retry budgets (per-request 3-attempt + per-client 10% ratio per Google SRE), deadline propagation, and the cascading-failure math (4 layers × 3 retries = 64x amplification). Grounded in Resilience4j, Microsoft Cloud Patterns, AWS Architecture Blog (Marc Brooker), and Google SRE Book.
testing
Designing HTTP cache headers that work correctly across browsers, CDNs, and shared proxies — `Cache-Control` directives per RFC 9111, `stale-while-revalidate` and `stale-if-error` per RFC 5861, the Vary header for varying responses, and surrogate keys for tag-based purging. Grounded in IETF RFCs and Cloudflare/Fastly docs.
development
Use when designing or fixing a Content Security Policy on a real site, choosing between nonce-based and hash-based CSP, adding strict-dynamic, debugging "Refused to execute inline script" errors, deploying CSP in report-only mode first, configuring report-to / report-uri, or auditing an existing policy for unsafe-inline / unsafe-eval / wildcards. Triggers: "CSP blocks legitimate inline script", strict-dynamic, nonce-{RANDOM}, sha256-{HASH}, object-src none, base-uri none, frame-ancestors, Trusted Types, X-Content-Security-Policy obsolete, report-only vs enforced. NOT for general HTTP security headers (HSTS, COOP/COEP), Trusted Types deep dive, CORS configuration, or building a WAF.
tools
Choosing and operating an HTTP API versioning strategy that doesn't break clients — Stripe's date-based pinned versions, the Deprecation/Sunset header pair (RFC 9745 + RFC 8594), URI vs header vs media-type approaches, and the version-transformer pattern. Grounded in Stripe's published architecture and IETF RFCs.