name: sweep description: "DFS graph crawler + full frontend audit. Playwright crawls the live app as a graph (pages=nodes, links=edges), DFS from entry, audits EVERY reachable node: multi-breakpoint screenshots (320/768/1440/1920px), WCAG 2.1 AA (axe-core), state verification (loading/error/empty/success), dark mode, interactive states, performance (LCP/CLS/FCP), visual regression, console errors, API validation, hydration. Auth support (storageState), CI mode (exit 1 on CRITICAL, text-only), max-pages cap, crawl failure recovery. Auto-detects 7 frameworks: Next.js, Vite/React, Vue/Nuxt, SvelteKit, Astro, Remix, Angular. Actions: test, crawl, audit, screenshot, verify, scan, regress, diff. Outputs: HTML graph report, topology map, per-node pass/fail, severity-rated issues."
Frontend Test - DFS Graph Crawler + Full Audit
The app is a graph. Every page is a node. Every link, button, and navigation is an edge. Playwright crawls the live app with DFS from the entry point. On every node it reaches, it runs the full test battery: breakpoints, states, dark mode, accessibility, performance, interactive elements. Nothing is skipped. Nothing is assumed.
When to Invoke
Use /sweep or invoke this skill when:
- After implementing any UI change (automatic in pipeline)
- Before committing frontend code
- User says: "test the frontend", "check the UI", "verify responsive", "crawl the app"
- As the VERIFY step in the delivery pipeline for any frontend work
- After variant selection and integration into real codebase
- When you need confidence that the ENTIRE app works, not just the page you changed
Prerequisites
# Playwright + axe-core
npx playwright --version 2>/dev/null || npm i -D @playwright/test && npx playwright install chromium
npm ls @axe-core/playwright 2>/dev/null || npm i -D @axe-core/playwright
Architecture: The App Graph
App = Directed Graph G(V, E)
V (nodes) = pages/routes reachable at runtime
E (edges) = links (<a>), client navigations (router.push), buttons that navigate,
form submissions, tab switches, modal openers — anything that changes URL or view
DFS from entry point (/) → visit every node exactly once
On each node → run full audit battery (Phases 1-7)
After crawl → analyze graph topology (dead ends, orphans, fan-in, cycles)
What DFS catches that targeted tests miss:
- Orphan pages — exist in router but no link reaches them (UX dead end)
- Dead ends — pages with zero outgoing links (user gets stuck)
- High fan-in nodes — linked from 10+ pages → breaking them = max blast radius
- Broken links — 404s, wrong routes, stale navigation
- Runtime-only errors — hydration mismatches, console errors, failed API calls that only happen on REAL navigation
- State leaks — navigation from page A leaves stale state visible on page B
Execution Protocol
Phase 0: Setup & Stack Detection
-
Detect stack — scan project root:
Signal Stack next.config.*Next.js (detect app router vs pages) vite.config.*+ ReactVite React vite.config.*+ VueVite Vue svelte.config.*SvelteKit nuxt.config.*Nuxt astro.config.*Astro (MPA + islands) remix.config.*orapp/root.tsx+ remixRemix angular.jsonAngular app.config.ts+ solid in package.jsonSolidStart *.htmlstandaloneStatic HTML -
Detect entry point:
- Next.js App Router →
http://localhost:3000/ - Pages Router →
http://localhost:3000/ - SPA →
http://localhost:5173/(Vite default) - Static → open
index.htmlvianpx serve
- Next.js App Router →
-
Start dev server if not running:
npm run dev & npx wait-on http://localhost:3000 --timeout 30000 -
Discover route manifest (for completeness check later):
- Next.js → read
app/**/page.tsxandpages/**/*.tsx - React Router → parse route config
- Vue Router → parse router/index.ts
- This is the "expected" node set — DFS discovers the "actual" set
- Next.js → read
Phase 0b: Auth Setup (if app has protected routes)
If the app has login-gated pages:
- Detect: look for
input[type=password]at entry point - Login: fill credentials → submit → wait for redirect
- Save state:
await context.storageState({ path: 'auth-state.json' }) - Reuse: all DFS contexts use
browser.newContext({ storageState: 'auth-state.json' })
CLI: --auth-state auth-state.json or --auth "user:pass"
Without auth, all protected routes show as status: 'error' (redirect to login).
Phase 1: DFS Graph Crawl (CORE)
The crawler. Starts at entry, follows every edge, audits every node.
// ─── Core Types ─────────────────────────────────────────────
interface GraphNode {
url: string;
normalizedPath: string; // /dashboard, /settings/profile
status: 'ok' | 'error' | 'crash' | 'unreachable';
depth: number; // distance from entry
parent: string | null; // which node led here (for path reconstruction)
// Edges discovered on this node
links: string[]; // <a href>, router links
interactions: Interaction[]; // buttons, forms, tabs that navigate
// Audit results (filled by Phases 2-7)
screenshots: ScreenshotSet;
states: StateResult[];
darkMode: DarkModeResult | null;
interactiveStates: InteractiveResult[];
accessibility: A11yResult;
performance: PerfResult;
// Runtime health
consoleErrors: string[];
apiCalls: ApiCall[];
hydrationOk: boolean;
jsErrors: string[];
}
interface Interaction {
selector: string;
type: 'button' | 'form' | 'tab' | 'accordion' | 'modal-trigger' | 'dropdown';
text: string;
navigatesTo: string | null; // if clicking causes navigation
}
interface ApiCall {
url: string;
method: string;
status: number;
duration: number;
ok: boolean;
responseSize: number;
}
interface AppGraph {
nodes: Map<string, GraphNode>;
edges: Map<string, Set<string>>;
entry: string;
crawlDuration: number;
timestamp: string;
}
DFS Algorithm
const BASE_URL = 'http://localhost:3000';
const MAX_DEPTH = 15;
const IGNORE_PATTERNS = [
/\.(png|jpg|svg|ico|woff|woff2|ttf|eot|css|js|map)$/,
/^mailto:/, /^tel:/, /^#$/, /^javascript:/,
/\/_next\//, /\/api\//, /\/favicon/,
];
async function crawlApp(browser: Browser): Promise<AppGraph> {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 }, // desktop default
});
const graph: AppGraph = {
nodes: new Map(),
edges: new Map(),
entry: BASE_URL,
crawlDuration: 0,
timestamp: new Date().toISOString(),
};
const visited = new Set<string>();
const startTime = Date.now();
// ── DFS: recursive depth-first traversal ──
async function dfs(url: string, depth: number, parentUrl: string | null) {
const normalized = normalizeUrl(url);
if (visited.has(normalized)) return;
if (depth > MAX_DEPTH) return;
if (!normalized.startsWith(BASE_URL)) return;
if (IGNORE_PATTERNS.some(p => p.test(normalized))) return;
visited.add(normalized);
console.log(`[${' '.repeat(depth)}DFS:${depth}] ${normalized}`);
const page = await context.newPage();
const node = await auditNode(page, normalized, depth, parentUrl);
graph.nodes.set(normalized, node);
graph.edges.set(normalized, new Set(node.links));
await page.close();
// Recurse into every discovered link — DFS order
for (const link of node.links) {
await dfs(link, depth + 1, normalized);
}
}
await dfs(BASE_URL, 0, null);
graph.crawlDuration = Date.now() - startTime;
await context.close();
return graph;
}
Crawl Failure Recovery
- Dev server dies: ping
BASE_URLbefore each node. If unreachable → write partialgraph.json→ generate report with available data → log[CRAWL ABORTED] - Node timeout cascade: if 3 consecutive nodes timeout → assume server is down → abort with partial report
- Max pages: default 100 nodes. Configurable via
--max-pages. When limit hit → stop DFS → report with crawled nodes - Total crawl timeout: 10 min hard ceiling → partial report
- Checkpoint: write
graph.jsonevery 10 nodes for crash recovery
Node Audit (runs on every page the DFS visits)
async function auditNode(
page: Page,
url: string,
depth: number,
parent: string | null
): Promise<GraphNode> {
const consoleErrors: string[] = [];
const jsErrors: string[] = [];
const apiCalls: ApiCall[] = [];
let hydrationOk = true;
// ── Listeners: capture everything that happens on this page ──
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
consoleErrors.push(text);
if (/hydrat/i.test(text)) hydrationOk = false;
}
});
page.on('pageerror', error => {
jsErrors.push(`${error.name}: ${error.message}`);
});
page.on('response', async res => {
const reqUrl = res.url();
// Capture API calls (fetch/XHR to /api/ or external)
if (reqUrl.includes('/api/') || reqUrl.includes('/graphql') ||
(reqUrl.startsWith('http') && !reqUrl.includes(BASE_URL))) {
apiCalls.push({
url: reqUrl,
method: res.request().method(),
status: res.status(),
duration: 0,
ok: res.ok(),
responseSize: (await res.body().catch(() => Buffer.alloc(0))).length,
});
}
});
// ── Navigate ──
let status: GraphNode['status'] = 'ok';
try {
const response = await page.goto(url, {
waitUntil: 'networkidle',
timeout: 15000,
});
if (!response) status = 'unreachable';
else if (response.status() >= 500) status = 'crash';
else if (response.status() >= 400) status = 'error';
} catch (e) {
status = 'unreachable';
return emptyNode(url, depth, parent, status, consoleErrors, jsErrors);
}
// Wait for framework hydration
await waitForHydration(page);
// ── Discover all edges (links + interactive navigations) ──
const links = await discoverLinks(page);
const interactions = await discoverInteractions(page);
// ── Run full audit battery (Phases 2-7) ──
const screenshots = await captureBreakpoints(page, url);
const states = await testStates(page, url);
const darkMode = await testDarkMode(page, url);
const interactiveStates = await testInteractiveStates(page);
const accessibility = await auditAccessibility(page);
const performance = await measurePerformance(page);
// ── Assemble node ──
return {
url,
normalizedPath: new URL(url).pathname,
status: determineStatus(status, consoleErrors, apiCalls, jsErrors),
depth,
parent,
links,
interactions,
screenshots,
states,
darkMode,
interactiveStates,
accessibility,
performance,
consoleErrors,
apiCalls,
hydrationOk,
jsErrors,
};
}
Edge Discovery (links + interactions)
async function discoverLinks(page: Page): Promise<string[]> {
return page.evaluate((base) => {
const seen = new Set<string>();
// 1. Standard <a href> links
document.querySelectorAll('a[href]').forEach(a => {
try {
const href = new URL(a.getAttribute('href')!, base).href;
if (href.startsWith(base)) seen.add(href);
} catch {}
});
// 2. Next.js Link components (rendered as <a>)
// Already covered above
// 3. Elements with onClick that use router.push (heuristic)
document.querySelectorAll('[data-href], [data-link]').forEach(el => {
const href = el.getAttribute('data-href') || el.getAttribute('data-link');
if (href) {
try { seen.add(new URL(href, base).href); } catch {}
}
});
// 4. Next.js route manifest (if available)
const nextData = (window as any).__NEXT_DATA__;
if (nextData?.buildManifest?.sortedPages) {
nextData.buildManifest.sortedPages.forEach((route: string) => {
if (!route.startsWith('/_')) seen.add(`${base}${route}`);
});
}
return [...seen];
}, BASE_URL);
}
async function discoverInteractions(page: Page): Promise<Interaction[]> {
return page.evaluate(() => {
const interactions: any[] = [];
const selectors = [
'button:not([disabled])',
'[role="button"]',
'input[type="submit"]',
'details > summary',
'[role="tab"]',
'[data-testid*="toggle"]',
'[data-testid*="open"]',
'[aria-haspopup]',
'[aria-expanded]',
];
document.querySelectorAll(selectors.join(', ')).forEach(el => {
const testId = el.getAttribute('data-testid') ?? '';
const text = el.textContent?.trim().slice(0, 60) ?? '';
const tag = el.tagName.toLowerCase();
const role = el.getAttribute('role') ?? '';
let type: string = 'button';
if (role === 'tab') type = 'tab';
if (el.closest('form')) type = 'form';
if (el.getAttribute('aria-haspopup')) type = 'dropdown';
if (testId.includes('modal') || testId.includes('dialog')) type = 'modal-trigger';
if (tag === 'summary') type = 'accordion';
interactions.push({
selector: testId ? `[data-testid="${testId}"]` : `${tag}:has-text("${text.slice(0, 30)}")`,
type,
text: text.slice(0, 60),
navigatesTo: null,
});
});
return interactions;
});
}
Framework Hydration Wait
async function waitForHydration(page: Page) {
// Next.js App Router
await page.waitForFunction(() => {
return !document.querySelector('[data-pending]') &&
!document.querySelector('#__next[data-reactroot]') || true;
}, { timeout: 5000 }).catch(() => {});
// Generic: wait for no pending network + DOM stable
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(500); // brief settle
}
Phase 2: Multi-Breakpoint Screenshots
Runs on EVERY node the DFS visits. 4 breakpoints per page.
interface ScreenshotSet {
mobile: string; // 320x568
tablet: string; // 768x1024
desktop: string; // 1440x900
wide: string; // 1920x1080
}
const BREAKPOINTS = [
{ name: 'mobile', width: 320, height: 568 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'wide', width: 1920, height: 1080 },
];
async function captureBreakpoints(page: Page, url: string): Promise<ScreenshotSet> {
const route = urlToFilename(url);
const result: any = {};
for (const bp of BREAKPOINTS) {
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.waitForTimeout(300); // layout settle
const path = `test-results/sweep/screenshots/${route}_${bp.name}.png`;
await page.screenshot({ path, fullPage: true });
result[bp.name] = path;
}
// Reset to desktop for remaining tests
await page.setViewportSize({ width: 1440, height: 900 });
return result;
}
Placement verification per breakpoint:
ELEMENT CHECKLIST (automated where possible, visual for the rest):
- Header/Nav: position, height, alignment, burger vs full menu
- Hero/Main content: width, centering, margins, max-width
- Sidebar: visible vs hidden, collapse behavior
- Cards/Grid: columns count, gap, overflow
- Typography: size scaling, line-height, truncation
- Buttons/CTA: touch target size (>=44px mobile), placement
- Images: aspect ratio, object-fit, lazy loading
- Footer: position, stacking order on mobile
- Forms: input width, label placement, error position
- No horizontal overflow at any breakpoint
Automated overflow detection:
async function checkOverflow(page: Page): Promise<string[]> {
return page.evaluate(() => {
const issues: string[] = [];
const docWidth = document.documentElement.clientWidth;
document.querySelectorAll('*').forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.right > docWidth + 1) {
const id = el.id || el.className?.toString().slice(0, 30) || el.tagName;
issues.push(`OVERFLOW: ${id} extends ${Math.round(rect.right - docWidth)}px beyond viewport`);
}
});
return issues;
});
}
Automated touch target check (mobile):
async function checkTouchTargets(page: Page): Promise<string[]> {
return page.evaluate(() => {
const issues: string[] = [];
const interactable = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [role="link"], [tabindex]'
);
interactable.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
issues.push(`SMALL TARGET: "${text}" is ${Math.round(rect.width)}x${Math.round(rect.height)}px (min 44x44)`);
}
});
return issues;
});
}
Phase 3: State Verification
For each node, test all data states by intercepting API calls:
interface StateResult {
state: 'loading' | 'success' | 'empty' | 'error' | 'partial';
screenshotPath: string;
passed: boolean;
issues: string[];
}
async function testStates(page: Page, url: string): Promise<StateResult[]> {
const route = urlToFilename(url);
const results: StateResult[] = [];
// Detect API patterns used by this page
const apiPatterns = await detectApiPatterns(page, url);
if (apiPatterns.length === 0) {
// Static page — only test success state
return [{ state: 'success', screenshotPath: '', passed: true, issues: [] }];
}
// ── Loading state ──
{
const newPage = await page.context().newPage();
await newPage.route('**/*', async route => {
if (apiPatterns.some(p => route.request().url().includes(p))) {
await new Promise(r => setTimeout(r, 10000)); // force slow
await route.continue();
} else {
await route.continue();
}
});
await newPage.goto(url, { waitUntil: 'commit' });
await newPage.waitForTimeout(1000);
const path = `test-results/sweep/screenshots/${route}_loading.png`;
await newPage.screenshot({ path });
const hasLoadingIndicator = await newPage.evaluate(() => {
const indicators = document.querySelectorAll(
'[class*="skeleton"], [class*="spinner"], [class*="loading"], ' +
'[role="progressbar"], [aria-busy="true"], [class*="shimmer"], ' +
'[class*="pulse"], [class*="animate-"]'
);
return indicators.length > 0;
});
results.push({
state: 'loading',
screenshotPath: path,
passed: hasLoadingIndicator,
issues: hasLoadingIndicator ? [] : ['No loading indicator found — page shows blank or broken state during load'],
});
await newPage.close();
}
// ── Empty state ──
{
const newPage = await page.context().newPage();
await newPage.route('**/*', async route => {
if (apiPatterns.some(p => route.request().url().includes(p))) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
} else {
await route.continue();
}
});
await newPage.goto(url, { waitUntil: 'networkidle' });
const path = `test-results/sweep/screenshots/${route}_empty.png`;
await newPage.screenshot({ path });
const hasEmptyState = await newPage.evaluate(() => {
const body = document.body.innerText.toLowerCase();
return body.includes('no ') || body.includes('empty') || body.includes('nothing') ||
body.includes('get started') || body.includes('create') ||
document.querySelector('[class*="empty"]') !== null;
});
results.push({
state: 'empty',
screenshotPath: path,
passed: hasEmptyState,
issues: hasEmptyState ? [] : ['No empty state UI — page shows blank or broken when data is empty'],
});
await newPage.close();
}
// ── Error state ──
{
const newPage = await page.context().newPage();
await newPage.route('**/*', async route => {
if (apiPatterns.some(p => route.request().url().includes(p))) {
await route.fulfill({ status: 500, body: 'Internal Server Error' });
} else {
await route.continue();
}
});
await newPage.goto(url, { waitUntil: 'networkidle' });
const path = `test-results/sweep/screenshots/${route}_error.png`;
await newPage.screenshot({ path });
const hasErrorUI = await newPage.evaluate(() => {
const body = document.body.innerText.toLowerCase();
return body.includes('error') || body.includes('retry') || body.includes('try again') ||
body.includes('something went wrong') || body.includes('failed') ||
document.querySelector('[class*="error"]') !== null ||
document.querySelector('button:has-text("Retry")') !== null;
});
const hasCrashed = await newPage.evaluate(() => {
// Check for React error boundary or blank page
return document.body.innerText.trim().length < 10 ||
document.querySelector('#__next')?.innerHTML === '' ||
document.body.innerText.includes('Application error');
});
results.push({
state: 'error',
screenshotPath: path,
passed: hasErrorUI && !hasCrashed,
issues: [
...(!hasErrorUI ? ['No error state UI — page shows blank or crashes on API error'] : []),
...(hasCrashed ? ['Page CRASHES on API error — no error boundary'] : []),
],
});
await newPage.close();
}
return results;
}
async function detectApiPatterns(page: Page, url: string): Promise<string[]> {
const patterns: string[] = [];
const newPage = await page.context().newPage();
newPage.on('request', req => {
const u = req.url();
if (u.includes('/api/') || u.includes('/graphql')) {
patterns.push(new URL(u).pathname);
}
});
await newPage.goto(url, { waitUntil: 'networkidle' });
await newPage.close();
return [...new Set(patterns)];
}
Phase 4: Dark Mode
interface DarkModeResult {
supported: boolean;
screenshotPath: string;
issues: string[];
}
async function testDarkMode(page: Page, url: string): Promise<DarkModeResult | null> {
// Detect support
const hasDarkMode = await page.evaluate(() => {
const html = document.documentElement.outerHTML;
return html.includes('dark:') || html.includes('data-theme') ||
html.includes('color-scheme') || document.querySelector('.dark') !== null ||
document.querySelector('[class*="theme"]') !== null;
});
// Also check CSS for prefers-color-scheme
const cssHasDark = await page.evaluate(() => {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.cssText?.includes('prefers-color-scheme: dark')) return true;
}
} catch {} // CORS stylesheet
}
return false;
});
if (!hasDarkMode && !cssHasDark) return null;
// Force dark mode
await page.emulateMedia({ colorScheme: 'dark' });
await page.waitForTimeout(500);
const route = urlToFilename(url);
const path = `test-results/sweep/screenshots/${route}_dark.png`;
await page.screenshot({ path, fullPage: true });
// Check for issues
const issues = await page.evaluate(() => {
const problems: string[] = [];
// Check for hardcoded white backgrounds
document.querySelectorAll('*').forEach(el => {
const styles = window.getComputedStyle(el);
const bg = styles.backgroundColor;
const color = styles.color;
// White background in dark mode = likely bug
if (bg === 'rgb(255, 255, 255)' && el.offsetWidth > 50 && el.offsetHeight > 20) {
const id = el.id || el.className?.toString().slice(0, 30) || el.tagName;
problems.push(`WHITE BG in dark mode: ${id}`);
}
// Very dark text on very dark bg = invisible
// (simplified check)
});
// Check for unstyled inputs
document.querySelectorAll('input, textarea, select').forEach(el => {
const bg = window.getComputedStyle(el).backgroundColor;
if (bg === 'rgb(255, 255, 255)') {
problems.push(`WHITE INPUT in dark mode: ${el.getAttribute('name') || el.type}`);
}
});
return problems;
});
// Reset
await page.emulateMedia({ colorScheme: 'light' });
return {
supported: true,
screenshotPath: path,
issues,
};
}
Phase 5: Interactive States
interface InteractiveResult {
element: string;
type: string;
hoverScreenshot: string | null;
focusScreenshot: string | null;
issues: string[];
}
async function testInteractiveStates(page: Page): Promise<InteractiveResult[]> {
const results: InteractiveResult[] = [];
// Get all interactive elements
const elements = await page.$$('button, a, input, [role="button"], [role="tab"], [tabindex="0"]');
// Test max 20 elements to avoid explosion
const toTest = elements.slice(0, 20);
for (let i = 0; i < toTest.length; i++) {
const el = toTest[i];
const info = await el.evaluate(e => ({
tag: e.tagName,
text: e.textContent?.trim().slice(0, 40) || '',
testId: e.getAttribute('data-testid') || '',
type: e.getAttribute('type') || '',
}));
const label = info.testId || info.text || `${info.tag}[${i}]`;
const issues: string[] = [];
// Hover state
let hoverPath: string | null = null;
try {
await el.hover();
await page.waitForTimeout(200);
// Check if hover changed anything (cursor, bg, shadow, transform)
const hasHoverEffect = await el.evaluate(e => {
const styles = window.getComputedStyle(e);
return styles.cursor === 'pointer' ||
styles.transform !== 'none' ||
styles.boxShadow !== 'none';
});
if (!hasHoverEffect && info.tag !== 'INPUT') {
issues.push(`No hover effect on clickable element: ${label}`);
}
} catch {}
// Focus state
try {
await el.focus();
await page.waitForTimeout(200);
const hasFocusRing = await el.evaluate(e => {
const styles = window.getComputedStyle(e);
return styles.outlineStyle !== 'none' ||
styles.boxShadow !== 'none' ||
styles.borderColor !== styles.getPropertyValue('--unfocused-border');
});
if (!hasFocusRing) {
issues.push(`No visible focus indicator: ${label}`);
}
} catch {}
// Focus trap check for modals
if (info.testId?.includes('modal') || info.testId?.includes('dialog')) {
try {
await el.click();
await page.waitForTimeout(500);
const dialog = await page.$('[role="dialog"], dialog, [class*="modal"]');
if (dialog) {
// Tab 20 times, check focus stays in dialog
for (let t = 0; t < 20; t++) {
await page.keyboard.press('Tab');
}
const focusInDialog = await page.evaluate(() => {
const active = document.activeElement;
return active?.closest('[role="dialog"], dialog, [class*="modal"]') !== null;
});
if (!focusInDialog) {
issues.push(`FOCUS TRAP BROKEN: focus escapes ${label} modal`);
}
// Close modal
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
} catch {}
}
results.push({
element: label,
type: info.tag.toLowerCase(),
hoverScreenshot: hoverPath,
focusScreenshot: null,
issues,
});
}
return results;
}
Phase 6: Accessibility Audit
interface A11yResult {
violations: A11yViolation[];
warnings: number;
passes: number;
headingOrder: boolean;
tabOrderCorrect: boolean;
skipLink: boolean;
contrastIssues: ContrastIssue[];
}
interface A11yViolation {
id: string;
impact: 'critical' | 'serious' | 'moderate' | 'minor';
description: string;
elements: string[];
fix: string;
}
interface ContrastIssue {
element: string;
ratio: number;
required: number;
foreground: string;
background: string;
}
async function auditAccessibility(page: Page): Promise<A11yResult> {
// axe-core scan
let violations: A11yViolation[] = [];
let warnings = 0;
let passes = 0;
try {
// Inject axe-core
await page.addScriptTag({
url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js',
});
const axeResults = await page.evaluate(async () => {
return await (window as any).axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
});
violations = axeResults.violations.map((v: any) => ({
id: v.id,
impact: v.impact,
description: v.description,
elements: v.nodes.map((n: any) => n.html.slice(0, 100)),
fix: v.nodes[0]?.failureSummary || v.help,
}));
warnings = axeResults.incomplete.length;
passes = axeResults.passes.length;
} catch (e) {
// axe failed — still do manual checks
}
// Manual: heading order
const headingOrder = await page.evaluate(() => {
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
let lastLevel = 0;
for (const h of headings) {
const level = parseInt(h.tagName[1]);
if (level > lastLevel + 1) return false; // skipped a level
lastLevel = level;
}
return true;
});
// Manual: tab order
const tabOrderCorrect = await page.evaluate(() => {
const focusable = Array.from(document.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
));
// Check no positive tabindex (anti-pattern)
return !focusable.some(el => {
const ti = el.getAttribute('tabindex');
return ti !== null && parseInt(ti) > 0;
});
});
// Manual: skip link
const skipLink = await page.evaluate(() => {
const first = document.querySelector('a');
return first?.textContent?.toLowerCase().includes('skip') ||
first?.getAttribute('href') === '#main' ||
first?.getAttribute('href') === '#content' || false;
});
// Manual: contrast (simplified — check text elements)
const contrastIssues: ContrastIssue[] = [];
// Full contrast check would use axe-core results above
return {
violations,
warnings,
passes,
headingOrder,
tabOrderCorrect,
skipLink,
contrastIssues,
};
}
Phase 7: Performance
interface PerfResult {
fcp: number; // First Contentful Paint (ms)
lcp: number; // Largest Contentful Paint (ms)
cls: number; // Cumulative Layout Shift
domContentLoaded: number;
loadComplete: number;
resourceCount: number;
totalTransferSize: number;
largestResource: { url: string; size: number };
issues: string[];
}
const PERF_THRESHOLDS = {
LCP: 2500,
FCP: 1800,
CLS: 0.1,
DOM_LOADED: 3000,
TRANSFER_SIZE: 3 * 1024 * 1024, // 3MB total
SINGLE_RESOURCE: 500 * 1024, // 500KB single resource
};
async function measurePerformance(page: Page): Promise<PerfResult> {
const issues: string[] = [];
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const paint = performance.getEntriesByType('paint');
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const fcp = paint.find(e => e.name === 'first-contentful-paint')?.startTime ?? 0;
// LCP
let lcp = 0;
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
if (lcpEntries.length) lcp = lcpEntries[lcpEntries.length - 1].startTime;
// CLS
let cls = 0;
const layoutShifts = performance.getEntriesByType('layout-shift');
for (const entry of layoutShifts) {
if (!(entry as any).hadRecentInput) cls += (entry as any).value;
}
// Resources
let totalSize = 0;
let largest = { url: '', size: 0 };
for (const r of resources) {
const size = r.transferSize || r.encodedBodySize || 0;
totalSize += size;
if (size > largest.size) largest = { url: r.name, size };
}
return {
fcp,
lcp,
cls,
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
loadComplete: nav.loadEventEnd - nav.startTime,
resourceCount: resources.length,
totalTransferSize: totalSize,
largestResource: largest,
};
});
// Check thresholds
if (metrics.lcp > PERF_THRESHOLDS.LCP) {
issues.push(`LCP ${Math.round(metrics.lcp)}ms > ${PERF_THRESHOLDS.LCP}ms threshold`);
}
if (metrics.fcp > PERF_THRESHOLDS.FCP) {
issues.push(`FCP ${Math.round(metrics.fcp)}ms > ${PERF_THRESHOLDS.FCP}ms threshold`);
}
if (metrics.cls > PERF_THRESHOLDS.CLS) {
issues.push(`CLS ${metrics.cls.toFixed(3)} > ${PERF_THRESHOLDS.CLS} threshold`);
}
if (metrics.totalTransferSize > PERF_THRESHOLDS.TRANSFER_SIZE) {
issues.push(`Total transfer ${(metrics.totalTransferSize / 1024 / 1024).toFixed(1)}MB > 3MB`);
}
if (metrics.largestResource.size > PERF_THRESHOLDS.SINGLE_RESOURCE) {
issues.push(`Large resource: ${metrics.largestResource.url.split('/').pop()} = ${(metrics.largestResource.size / 1024).toFixed(0)}KB`);
}
// Check images without dimensions
const unsizedImages = await page.evaluate(() => {
return Array.from(document.querySelectorAll('img'))
.filter(img => !img.width && !img.height && !img.style.width && !img.style.height)
.map(img => img.src.split('/').pop() || 'unknown')
.slice(0, 5);
});
if (unsizedImages.length) {
issues.push(`Images without explicit dimensions: ${unsizedImages.join(', ')}`);
}
return { ...metrics, issues };
}
Graph Analysis (Post-Crawl)
After DFS completes, analyze the graph topology:
interface GraphAnalysis {
totalNodes: number;
reachableFromEntry: number;
orphanRoutes: string[]; // in manifest but not reached by DFS
deadEnds: string[]; // 0 outgoing links
highFanIn: { url: string; fanIn: number }[]; // most linked-to
brokenNodes: GraphNode[]; // status != 'ok'
failedAPIs: { page: string; calls: ApiCall[] }[];
hydrationErrors: string[];
consoleErrorPages: { page: string; errors: string[] }[];
a11yWorstPages: { page: string; violations: number }[];
perfWorstPages: { page: string; lcp: number }[];
cycles: string[][]; // circular navigation paths
maxDepth: number;
avgDepth: number;
}
function analyzeGraph(graph: AppGraph, routeManifest: string[]): GraphAnalysis {
const fanIn = new Map<string, number>();
for (const [, edges] of graph.edges) {
for (const target of edges) {
fanIn.set(target, (fanIn.get(target) ?? 0) + 1);
}
}
// Orphan routes: in manifest but DFS never found them
const reachedPaths = new Set([...graph.nodes.keys()].map(u => new URL(u).pathname));
const orphanRoutes = routeManifest.filter(r => !reachedPaths.has(r));
// Dead ends: 0 outgoing
const deadEnds = [...graph.nodes.entries()]
.filter(([url]) => (graph.edges.get(url)?.size ?? 0) === 0)
.map(([url]) => url);
// High fan-in: sort by most linked-to
const highFanIn = [...fanIn.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([url, fi]) => ({ url, fanIn: fi }));
// Broken nodes
const brokenNodes = [...graph.nodes.values()].filter(n => n.status !== 'ok');
// Failed APIs
const failedAPIs = [...graph.nodes.entries()]
.filter(([, n]) => n.apiCalls.some(c => !c.ok))
.map(([page, n]) => ({ page, calls: n.apiCalls.filter(c => !c.ok) }));
// Hydration errors
const hydrationErrors = [...graph.nodes.entries()]
.filter(([, n]) => !n.hydrationOk)
.map(([url]) => url);
// Console errors
const consoleErrorPages = [...graph.nodes.entries()]
.filter(([, n]) => n.consoleErrors.length > 0)
.map(([page, n]) => ({ page, errors: n.consoleErrors }));
// Worst a11y
const a11yWorstPages = [...graph.nodes.entries()]
.filter(([, n]) => n.accessibility.violations.length > 0)
.sort((a, b) => b[1].accessibility.violations.length - a[1].accessibility.violations.length)
.slice(0, 10)
.map(([page, n]) => ({ page, violations: n.accessibility.violations.length }));
// Worst perf
const perfWorstPages = [...graph.nodes.entries()]
.filter(([, n]) => n.performance.lcp > 0)
.sort((a, b) => b[1].performance.lcp - a[1].performance.lcp)
.slice(0, 10)
.map(([page, n]) => ({ page, lcp: n.performance.lcp }));
// Depth stats
const depths = [...graph.nodes.values()].map(n => n.depth);
const maxDepth = Math.max(...depths);
const avgDepth = depths.reduce((a, b) => a + b, 0) / depths.length;
// Cycle detection (simplified DFS)
const cycles: string[][] = [];
// ... (standard cycle detection via DFS coloring)
return {
totalNodes: graph.nodes.size,
reachableFromEntry: graph.nodes.size,
orphanRoutes,
deadEnds,
highFanIn,
brokenNodes,
failedAPIs,
hydrationErrors,
consoleErrorPages,
a11yWorstPages,
perfWorstPages,
cycles,
maxDepth,
avgDepth,
};
}
HTML Report Generation
After crawl + analysis, generate a visual HTML report:
test-results/sweep/
├── report.html ← Main report (open in browser)
├── graph.json ← Full graph data (for programmatic use)
├── screenshots/
│ ├── home_mobile.png
│ ├── home_tablet.png
│ ├── home_desktop.png
│ ├── home_wide.png
│ ├── home_loading.png
│ ├── home_empty.png
│ ├── home_error.png
│ ├── home_dark.png
│ ├── dashboard_mobile.png
│ └── ... (4+ screenshots per node)
├── baselines/ ← Visual regression baselines
├── diffs/ ← Visual diff images
└── accessibility/
└── axe-results.json
Report sections:
- Graph overview — node count, edge count, depth stats, crawl time
- Topology issues — orphan routes, dead ends, high fan-in nodes
- Health dashboard — per-node status (green/yellow/red) in a table
- Screenshot gallery — 4 breakpoints side-by-side per route
- State verification — loading/empty/error screenshots per route
- Dark mode — light vs dark comparison per route
- Accessibility — violations table sorted by severity
- Performance — metrics table with threshold coloring
- Console errors — grouped by page
- API failures — grouped by page with status codes
- Visual regression — diff images (if baselines exist)
Quick Commands
/sweep— Full DFS crawl + all audits on entire app/sweep <url>— Single-node audit (skip DFS, test one page)/sweep crawl— DFS crawl only (discover graph, no deep audit)/sweep screenshots— Multi-breakpoint screenshots only/sweep a11y— Accessibility audit only/sweep dark— Dark mode verification only/sweep perf— Performance audit only/sweep states— State verification only (loading/error/empty)/sweep interactive— Interactive states only (hover/focus/active)/sweep regression— Visual regression against baselines/sweep update-baselines— Update visual regression baselines/sweep edges— Decision tree edge cases only/sweep report— Regenerate HTML report from last crawl data/sweep topology— Graph analysis only (dead ends, orphans, fan-in)/sweep auth <state-file>— Crawl with authentication (storageState)/sweep ci— CI mode: no screenshots, text-only output, exit 1 on CRITICAL/sweep diff— Compare current run against previous run
Agent Strategy
Parallel by route cluster, not by phase:
After the initial DFS crawl discovers all routes, group them into clusters and run audits in parallel:
Phase A: DFS Crawl (sequential — must be DFS)
→ Discovers N routes, collects links + basic health
Phase B: Deep Audit (parallel — one agent per route cluster)
Agent 1 (sonnet, bg) → audit routes [/, /about, /pricing]
Agent 2 (sonnet, bg) → audit routes [/dashboard, /dashboard/settings]
Agent 3 (sonnet, bg) → audit routes [/auth/login, /auth/signup, /auth/forgot]
...
Phase C: Synthesis (main thread)
→ Merge results → graph analysis → generate report → open in browser
Agent failure handling:
- Each agent writes partial results to
test-results/sweep/agent-{name}.json - If agent dies: main thread retries cluster once with fresh agent
- If retry fails: mark nodes as
status: 'agent-crash'in report - Never block final report — generate with whatever data exists
Each agent prompt MUST include:
- List of URLs to audit
- Dev server port
- Screenshot output dir (unique per agent to avoid file conflicts)
- Shared browser context for auth state
- "MAX 200 LINES output. Details in files, return paths."
Severity Ratings
| Severity | Criteria | Action |
|---|---|---|
| CRITICAL | Page crash/unreachable, JS errors blocking render, WCAG A violation, broken API (500), hydration crash, CLS > 0.25 | Block commit |
| HIGH | Missing state (no loading/error/empty), contrast fail, LCP > 4s, broken interactive state, focus trap broken, orphan route | Fix before merge |
| MEDIUM | Minor misalignment, non-critical a11y warning, LCP 2.5-4s, hover state missing, dead end page, console warning | Fix this sprint |
| LOW | Cosmetic inconsistency, perf suggestion, best practice, minor contrast | Backlog |
Stack-Specific Adjustments
Next.js (App Router)
- Wait for hydration:
await page.waitForFunction(() => !document.querySelector('[data-pending]')) - Discover routes from
app/**/page.tsxfile structure - Test RSC streaming: throttle, verify suspense boundaries show fallback
- Check
loading.tsxanderror.tsxexist for each route group - Verify
metadataexports produce correct<title>and<meta>
Next.js (Pages Router)
- Discover routes from
pages/**/*.tsx - Check
_app.tsx,_document.tsx,404.tsx,500.tsx
Vite + React (SPA)
- Discover routes from React Router config
- All routes share one entry — DFS via client navigation
- Check code splitting:
React.lazy()routes should produce separate chunks
Vue / Nuxt
- Discover routes from
router/index.tsorpages/directory - Wait for
$nextTickafter navigation - Check
<Transition>components
SvelteKit
- Discover routes from
routes/directory structure - Check
+page.server.tsload function error handling - Test progressive enhancement (
use:enhance)
Remix
- Discover routes from
app/routes/directory (file-based routing) - Test
loader/actionerror boundaries - Wait for deferred data: check for
<Await>components
Astro
- Test both SSR and client-hydrated islands separately
- Routes from
src/pages/directory - Islands may render empty on initial load — wait for
astro:idleevent
Angular
- Wait for zone stability:
await page.waitForFunction(() => (window as any).getAllAngularTestabilities?.()[0]?.isStable()) - Discover routes from
RouterModuleconfiguration - Check for lazy-loaded modules
Static HTML
- Entry point =
index.html - Discover links by crawling
<a href>tags - No framework overhead — simpler but same audit battery
Integration with Pipeline
This skill is the VERIFY step for frontend in the delivery pipeline:
impl → sweep (this skill) → review
↓ fail?
fix → re-test (max 3 iterations)
Auto-invocation triggers:
- File change matching:
*.tsx,*.vue,*.svelte,*.html,*.css,*.scss - Directory change matching:
components/*,pages/*,app/*,styles/*
Minimum viable run (for TRIVIAL changes):
- Single-node audit on the changed route only
- Skip full DFS crawl
Full crawl (for STANDARD+ changes):
- Complete DFS + all audits + report generation
Running the Crawler
# From your project root (playwright must be in node_modules)
npx tsx ~/.claude/skills/sweep/scripts/crawler.ts --base http://localhost:3000
# With options
npx tsx ~/.claude/skills/sweep/scripts/crawler.ts \
--base http://localhost:3000 \
--max-depth 10 \
--max-pages 50 \
--out test-results/sweep
CI Integration
- name: Frontend Test (DFS Crawl)
run: |
npm run dev &
npx wait-on http://localhost:3000 --timeout 30000
npx tsx .claude/skills/sweep/scripts/crawler.ts --base http://localhost:3000 --ci --max-pages 50
# CI mode: text output only, exit 1 on CRITICAL
Checklist Template
### Frontend Test Results — Full App Crawl
#### Graph Health
- [ ] All routes reachable from entry (0 orphans)
- [ ] No dead-end pages (every page has >=1 outgoing link)
- [ ] No broken pages (0 crash/unreachable nodes)
- [ ] No console errors on any page
- [ ] No failed API calls on any page
- [ ] No hydration mismatches
#### Per-Node (repeat for each route)
**Breakpoints:**
- [ ] 320px — mobile layout, touch targets >= 44px, no overflow
- [ ] 768px — tablet layout correct
- [ ] 1440px — desktop layout correct
- [ ] 1920px — max-width respected
**States:**
- [ ] Loading — indicator visible
- [ ] Success — renders correctly
- [ ] Empty — empty state + CTA
- [ ] Error — error message + retry, no crash
**Dark Mode:**
- [ ] No white flashes, all text readable, inputs styled
**Interactive:**
- [ ] Hover/focus/active on buttons, links, inputs
- [ ] Focus traps on modals
- [ ] Full keyboard navigation
**Accessibility:**
- [ ] axe-core: 0 violations
- [ ] Heading hierarchy correct
- [ ] Focus visible everywhere
**Performance:**
- [ ] LCP < 2.5s
- [ ] CLS < 0.1
- [ ] No oversized resources