Frontend Test - DFS Graph Crawler + Full Audit
"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 verificatio
Free to install β no account needed
Copy the command below and paste into your agent.
Instant access β’ No coding needed β’ No account needed
What you get in 5 minutes
- Full skill code ready to install
- Works with 2 AI agents
- Lifetime updates included
Description
--- 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 (<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 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[]; // <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 ```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<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_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<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) ```typescript 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 ```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<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:** ```typescript 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):** ```typescript 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: ```typescript 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 ```typescript 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 ```typescript 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 ```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<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 ```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<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: ```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<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:** 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 <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.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 `<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.ts` or `pages/` directory - Wait for `$nextTick` after navigation - Check `<Transition>` 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 `<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: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 `<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 ```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 ```
Security Status
Unvetted
Not yet security scanned
Related AI Tools
More Grow Business tools you might like
Clawra Selfie
FreeEdit Clawra's reference image with Grok Imagine (xAI Aurora) and send selfies to messaging channels via OpenClaw
Agent Skills for Context Engineering
FreeA comprehensive collection of Agent Skills for context engineering, multi-agent architectures, and production agent systems. Use when building, optimizing, or debugging agent systems that require effective context management.
Terraform Skill for Claude
FreeUse when working with Terraform or OpenTofu - creating modules, writing tests (native test framework, Terratest), setting up CI/CD pipelines, reviewing configurations, choosing between testing approaches, debugging state issues, implementing security
NotebookLM Research Assistant Skill
FreeUse this skill to query your Google NotebookLM notebooks directly from Claude Code for source-grounded, citation-backed answers from Gemini. Browser automation, library management, persistent auth. Drastically reduced hallucinations through document-
Engineering Advanced Skills (POWERFUL Tier)
Free"25 advanced engineering agent skills and plugins for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw. Agent design, RAG, MCP servers, CI/CD, database design, observability, security auditing, release management, platform ops."
Clawra Selfie
FreeEdit Clawra's reference image with Grok Imagine (xAI Aurora) and send selfies to messaging channels via OpenClaw