Skip to content

Execution Flow

Matches docs/FLOW.md in the repository — update both together if the pipeline changes.

scan — multi-route scan with dev-server resolution

Section titled “scan — multi-route scan with dev-server resolution”
user
│ npx frontend-qa scan [--url ...] [--viewport WxH] [--har]
│ [--discover-routes] [--a11y] [--perf] [--visual]
cli
├─ parse args (commander)
├─ detectProject(cwd) → { framework, hasVite, devScript?, packageManager }
├─ resolveConfig(args, cwd) → ResolvedConfig (validated by Zod)
├─ if no --url:
│ findRunningDevServer() → probe 127.0.0.1:{5173,3000,4173,8080}, reuse first hit
│ else launchDevServer() → spawn detected dev script, poll guessed port, own lifecycle
browser.runBrowserScan
├─ startRun() → runId (ULID), startedAt
├─ if --perf: getFreePort() for Lighthouse's own CDP connection
├─ launch Playwright (chromium, headless by default, +--remote-debugging-port if --perf)
├─ new BrowserContext + Page (viewport from config, recordHar if --har)
├─ register collectors:
│ console → page.on('console'), page.on('pageerror')
│ network → page.on('requestfinished'), page.on('requestfailed')
│ screenshot→ captured post-navigation
│ timing → performance.timing snapshot
│ a11y → (--a11y) inject axe-core, page.evaluate(axe.run()) per route
│ perf → (--perf) lighthouse(url, { port: cdpPort, onlyCategories:['performance'] })
│ visual → (--visual) screenshot vs baseline/<route>.png via pixelmatch
├─ routeQueue = [...config.routes] (grows if --discover-routes finds new links)
├─ for each route:
│ page.goto(url + route, { waitUntil: 'networkidle', timeout })
│ waitForQuiet(page) ← no new console/pageerror/request for 200ms, capped 1500ms
│ collectors.onNavigate(route)
│ if --discover-routes: scrape same-origin <a href>, enqueue unseen (cap 20)
├─ teardown all collectors → typed results
core.normalize
├─ collector.toIssues(result, ctx) → Issue[]
├─ dedup by stable id
├─ apply severity + evidence tags
core.report
├─ Report Zod schema validation
├─ write .local/frontend-qa/runs/<runId>/report.json
├─ move screenshots into runs/<runId>/screenshots/; network.har alongside if --har
cli output
├─ human-readable summary to stdout (counts by severity + collector)
├─ if the CLI launched the dev server itself, stop it (finally block)
└─ exit(2) if no URL resolved; else exit(1) if issues ≥ --fail-on, else exit(0)
user
│ npx frontend-qa bundle --dir dist/assets
cli → core.analyzeBundle(dir)
├─ walk dir recursively, collect .js/.css files + sizes
├─ flag files ≥250KB (medium) / ≥500KB (high)
cli output → summary to stdout, exit(1) if any high/critical issue

No Report/runId/browser involved — filesystem-only.

user
│ npx frontend-qa static [--dir src]
cli → core.analyzeStatic(dir)
├─ ts-morph Project loads every .ts/.tsx/.js/.jsx under dir
├─ per file: missing <img alt>, dangerouslySetInnerHTML, TODO/FIXME regex,
│ unresolved relative imports (filesystem existence check)
├─ project-wide: dead PascalCase exports (zero cross-file references)
cli output → summary to stdout, exit(1) if any high/critical issue

Findings carry source.url/source.line (and element.snippet for JSX findings) so they can be cross-referenced against runtime issues without a schema difference.

AI client (Claude Code, etc.)
│ spawns `node packages/mcp-server/dist/bin.js` (stdio transport)
mcp-server.bin → runStdioServer() → createServer() → registers 5 tools
├─ frontend.run_scan(args)
│ ├─ detectProject(cwd), resolveConfig(args)
│ ├─ resolveDevServerUrl(cwd, project, config) ← same helper the CLI uses
│ ├─ runBrowserScan({ cwd, config, project }) ← same orchestrator the CLI uses
│ └─ returns a text summary (runId, reportPath, issue counts by severity)
├─ frontend.get_report(runId?) → readReport() → full Report JSON (text)
├─ frontend.get_issues(runId?, minSeverity?, collector?, category?)
│ → readReport() → filter → Issue[] JSON (text)
├─ frontend.get_console_errors(runId?)
│ → readReport() → filter collector === 'browser.console' → Issue[] JSON (text)
└─ frontend.get_screenshot(runId?, route?)
→ readReport() → match artifacts.screenshots by slug(route) → read PNG
→ return as MCP image content (base64 + mimeType)

readReport()/findLatestRunId() default to the most recent run (ULID run IDs sort lexicographically by creation time) when no runId is given. No new analysis logic — every tool is a thin wrapper over core/browser functions the CLI already uses.

raw header/body ─► redactHeaders() / redactString() ─► Issue.metadata (safe) ─► JSON

Redaction happens at the collector boundary, never later — original values never touch disk.