---
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
```bash
# 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 (), 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
1. **Detect stack** — scan project root:
| Signal | Stack |
|--------|-------|
| `next.config.*` | Next.js (detect app router vs pages) |
| `vite.config.*` + React | Vite React |
| `vite.config.*` + Vue | Vite Vue |
| `svelte.config.*` | SvelteKit |
| `nuxt.config.*` | Nuxt |
| `astro.config.*` | Astro (MPA + islands) |
| `remix.config.*` or `app/root.tsx` + remix | Remix |
| `angular.json` | Angular |
| `app.config.ts` + solid in package.json | SolidStart |
| `*.html` standalone | Static HTML |
2. **Detect entry point:**
- Next.js App Router → `http://localhost:3000/`
- Pages Router → `http://localhost:3000/`
- SPA → `http://localhost:5173/` (Vite default)
- Static → open `index.html` via `npx serve`
3. **Start dev server** if not running:
```bash
npm run dev &
npx wait-on http://localhost:3000 --timeout 30000
```
4. **Discover route manifest** (for completeness check later):
- Next.js → read `app/**/page.tsx` and `pages/**/*.tsx`
- React Router → parse route config
- Vue Router → parse router/index.ts
- This is the "expected" node set — DFS discovers the "actual" set
---
### Phase 0b: Auth Setup (if app has protected routes)
If the app has login-gated pages:
1. **Detect**: look for `input[type=password]` at entry point
2. **Login**: fill credentials → submit → wait for redirect
3. **Save state**: `await context.storageState({ path: 'auth-state.json' })`
4. **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.
```typescript
// ─── 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[]; // , 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;
edges: Map>;
entry: string;
crawlDuration: number;
timestamp: string;
}
```
#### DFS Algorithm
```typescript
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 {
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();
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_URL` before each node. If unreachable → write partial `graph.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.json` every 10 nodes for crash recovery
#### Node Audit (runs on every page the DFS visits)
```typescript
async function auditNode(
page: Page,
url: string,
depth: number,
parent: string | null
): Promise {
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)
```typescript
async function discoverLinks(page: Page): Promise {
return page.evaluate((base) => {
const seen = new Set();
// 1. Standard 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 )
// 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 {
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
```typescript
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.
```typescript
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 {
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:**
```typescript
async function checkOverflow(page: Page): Promise {
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):**
```typescript
async function checkTouchTargets(page: Page): Promise {
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:
```typescript
interface StateResult {
state: 'loading' | 'success' | 'empty' | 'error' | 'partial';
screenshotPath: string;
passed: boolean;
issues: string[];
}
async function testStates(page: Page, url: string): Promise {
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 {
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
```typescript
interface DarkModeResult {
supported: boolean;
screenshotPath: string;
issues: string[];
}
async function testDarkMode(page: Page, url: string): Promise {
// 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
```typescript
interface InteractiveResult {
element: string;
type: string;
hoverScreenshot: string | null;
focusScreenshot: string | null;
issues: string[];
}
async function testInteractiveStates(page: Page): Promise {
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
```typescript
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 {
// 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
```typescript
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 {
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:
```typescript
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();
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:**
1. **Graph overview** — node count, edge count, depth stats, crawl time
2. **Topology issues** — orphan routes, dead ends, high fan-in nodes
3. **Health dashboard** — per-node status (green/yellow/red) in a table
4. **Screenshot gallery** — 4 breakpoints side-by-side per route
5. **State verification** — loading/empty/error screenshots per route
6. **Dark mode** — light vs dark comparison per route
7. **Accessibility** — violations table sorted by severity
8. **Performance** — metrics table with threshold coloring
9. **Console errors** — grouped by page
10. **API failures** — grouped by page with status codes
11. **Visual regression** — diff images (if baselines exist)
---
## Quick Commands
- `/sweep` — Full DFS crawl + all audits on entire app
- `/sweep ` — 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 ` — 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.tsx` file structure
- Test RSC streaming: throttle, verify suspense boundaries show fallback
- Check `loading.tsx` and `error.tsx` exist for each route group
- Verify `metadata` exports produce correct `` and ``
### 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.ts` or `pages/` directory
- Wait for `$nextTick` after navigation
- Check `` components
### SvelteKit
- Discover routes from `routes/` directory structure
- Check `+page.server.ts` load function error handling
- Test progressive enhancement (`use:enhance`)
### Remix
- Discover routes from `app/routes/` directory (file-based routing)
- Test `loader`/`action` error boundaries
- Wait for deferred data: check for `` 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:idle` event
### Angular
- Wait for zone stability: `await page.waitForFunction(() => (window as any).getAllAngularTestabilities?.()[0]?.isStable())`
- Discover routes from `RouterModule` configuration
- Check for lazy-loaded modules
### Static HTML
- Entry point = `index.html`
- Discover links by crawling `` 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
```bash
# 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
```yaml
- 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
```markdown
### 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
```