Back to Marketplace
FREE
Unvetted
Grow Business

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

New skill
No reviews yet
New skill
πŸ€– Claude Code⚑ Cursor
FREE

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
SecureBe the first

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 ```

Preview in:

Security Status

Unvetted

Not yet security scanned

Related AI Tools

More Grow Business tools you might like