MCP QA Agent - Guia Completo de Implementação
MCP QA Agent - Guia Completo de Implementação
Visão Geral
Este documento descreve a implementação de um MCP (Model Context Protocol) Server que permite ao Claude Code executar testes automatizados de frontend usando Playwright, capturando logs do console, erros de rede e screenshots para validar se as features implementadas estão funcionando corretamente.
Arquitetura
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ MCP Server │ │
│ │ (qa-agent) │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Context │ │ Playwright │ │ Flow │ │
│ │ Loader │ │ Runner │ │ Manager │ │
│ │ (docs/*.md) │ │ (browser) │ │ (yaml) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Resultados │ │
│ │ + Análise │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Funcionalidades
- Carregamento de contexto: Lê documentação do projeto (docs/*.md) para entender a estrutura
- Execução de fluxos: Roda testes definidos em YAML no navegador real
- Captura completa: Console logs, erros JS, requisições de rede, screenshots
- Análise automática: Identifica problemas e sugere correções
- Integração Claude Code: Ferramentas disponíveis diretamente no chat
Pré-requisitos
Sistema
- Node.js 18+
- npm ou yarn
- Chromium (instalado pelo Playwright)
Conhecimento do Projeto
- Estrutura de pastas do monorepo
- URL base do frontend em desenvolvimento
- Fluxos principais da aplicação
Estrutura do Projeto
Localização Recomendada
seu-monorepo/
├── apps/
│ └── frontend/
├── packages/
├── docs/ # Documentação existente do projeto
│ ├── arquitetura.md
│ ├── fluxos.md
│ └── ...
└── tools/
└── mcp-qa-agent/ # ← MCP Server aqui
├── src/
│ ├── index.ts
│ ├── server.ts
│ ├── tools/
│ │ ├── index.ts
│ │ ├── load-context.ts
│ │ ├── validate-page.ts
│ │ ├── run-flow.ts
│ │ └── manage-flows.ts
│ ├── playwright/
│ │ ├── browser-manager.ts
│ │ └── page-analyzer.ts
│ ├── context/
│ │ └── docs-parser.ts
│ └── types/
│ └── index.ts
├── flows/
│ ├── _template.yaml
│ ├── login.yaml
│ └── criar-licitacao.yaml
├── screenshots/
├── package.json
├── tsconfig.json
└── README.md
Passo 1: Inicialização do Projeto
1.1 Criar a estrutura de diretórios
# Na raiz do seu monorepo
mkdir -p tools/mcp-qa-agent/{src/{tools,playwright,context,types},flows,screenshots}
cd tools/mcp-qa-agent
1.2 Inicializar o projeto Node.js
npm init -y
1.3 Instalar dependências
npm install @modelcontextprotocol/sdk playwright yaml glob gray-matter zod
npm install -D typescript @types/node tsx
1.4 Criar package.json
Substituir o conteúdo do package.json:
{
"name": "mcp-qa-agent",
"version": "1.0.0",
"description": "MCP Server para QA automatizado com Playwright",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"postinstall": "npx playwright install chromium"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"glob": "^10.3.10",
"gray-matter": "^4.0.3",
"playwright": "^1.40.0",
"yaml": "^2.3.4",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.6.2",
"typescript": "^5.3.2"
}
}
1.5 Criar tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Passo 2: Tipos e Interfaces
2.1 Criar src/types/index.ts
// src/types/index.ts
/**
* Configuração do MCP QA Agent
*/
export interface QAAgentConfig {
/** Caminho raiz do projeto monorepo */
projectRoot: string;
/** Caminho relativo para pasta de docs (padrão: "docs") */
docsPath: string;
/** URL base do frontend (padrão: "http://localhost:3000") */
baseUrl: string;
/** Caminho para salvar screenshots */
screenshotsPath: string;
/** Caminho para os fluxos de teste */
flowsPath: string;
/** Executar em modo headless (padrão: false para debug) */
headless: boolean;
/** Timeout padrão em ms (padrão: 30000) */
defaultTimeout: number;
}
/**
* Contexto do projeto carregado dos docs
*/
export interface ProjectContext {
/** Nome do projeto */
name: string;
/** Descrição geral */
description: string;
/** Módulos identificados */
modules: ProjectModule[];
/** Entidades/Models do sistema */
entities: string[];
/** Rotas do frontend identificadas */
routes: RouteInfo[];
/** Arquivos de doc processados */
docsProcessed: string[];
/** Timestamp do carregamento */
loadedAt: Date;
}
export interface ProjectModule {
name: string;
description: string;
features: string[];
relatedDocs: string[];
}
export interface RouteInfo {
path: string;
name: string;
module?: string;
requiresAuth: boolean;
}
/**
* Definição de um fluxo de teste
*/
export interface TestFlow {
/** Identificador único do fluxo */
id: string;
/** Nome legível */
name: string;
/** Descrição do que o fluxo testa */
description: string;
/** Módulo relacionado */
module?: string;
/** Se requer autenticação */
requiresAuth: boolean;
/** Role do usuário para o teste */
userRole?: string;
/** Credenciais de teste (se requiresAuth) */
credentials?: {
email: string;
password: string;
};
/** Pré-condições descritas */
preconditions: string[];
/** URL inicial (relativa ao baseUrl) */
startUrl: string;
/** Passos do teste */
steps: TestStep[];
/** Validações finais */
validations: string[];
}
export interface TestStep {
/** Nome descritivo do passo */
name: string;
/** Tipo de ação */
action: TestAction;
/** Seletor CSS ou data-testid */
selector?: string;
/** URL para navegação */
url?: string;
/** Valor para input */
value?: string;
/** Campos para fill-form */
fields?: Record<string, string>;
/** Timeout específico deste step em ms */
timeout?: number;
/** Se deve tirar screenshot após este step */
screenshot?: boolean;
/** Se o step é opcional (não falha o fluxo se der erro) */
optional?: boolean;
}
export type TestAction =
| 'navigate'
| 'click'
| 'fill'
| 'fill-form'
| 'select'
| 'check'
| 'uncheck'
| 'wait'
| 'wait-url'
| 'assert-visible'
| 'assert-hidden'
| 'assert-text'
| 'assert-url'
| 'screenshot'
| 'scroll'
| 'hover'
| 'press-key';
/**
* Log capturado do console do navegador
*/
export interface CapturedLog {
/** Tipo: log, warn, error, info, debug */
type: 'log' | 'warn' | 'error' | 'info' | 'debug';
/** Conteúdo da mensagem */
message: string;
/** URL onde ocorreu */
url: string;
/** Linha e coluna se disponível */
location?: {
url: string;
lineNumber: number;
columnNumber: number;
};
/** Timestamp */
timestamp: Date;
/** Stack trace se for erro */
stack?: string;
}
/**
* Requisição de rede capturada
*/
export interface CapturedRequest {
url: string;
method: string;
status?: number;
statusText?: string;
/** Se a requisição falhou */
failed: boolean;
/** Motivo da falha */
failureText?: string;
/** Tempo de resposta em ms */
timing?: number;
/** Tipo de recurso */
resourceType: string;
/** Timestamp */
timestamp: Date;
}
/**
* Resultado de um step executado
*/
export interface StepResult {
/** Nome do step */
name: string;
/** Se passou */
success: boolean;
/** Tempo de execução em ms */
duration: number;
/** Mensagem de erro se falhou */
error?: string;
/** Caminho do screenshot se capturado */
screenshot?: string;
/** Se foi pulado */
skipped: boolean;
}
/**
* Resultado completo de um fluxo de teste
*/
export interface FlowResult {
/** ID do fluxo */
flowId: string;
/** Nome do fluxo */
flowName: string;
/** Se passou completamente */
success: boolean;
/** Timestamp de início */
startedAt: Date;
/** Timestamp de fim */
finishedAt: Date;
/** Duração total em ms */
totalDuration: number;
/** Resultados de cada step */
steps: StepResult[];
/** Total de steps que passaram */
passedSteps: number;
/** Total de steps que falharam */
failedSteps: number;
/** Total de steps pulados */
skippedSteps: number;
/** Logs do console capturados */
consoleLogs: CapturedLog[];
/** Erros de console (filtrados) */
consoleErrors: CapturedLog[];
/** Warnings de console (filtrados) */
consoleWarnings: CapturedLog[];
/** Requisições capturadas */
networkRequests: CapturedRequest[];
/** Requisições que falharam */
failedRequests: CapturedRequest[];
/** Screenshots capturados */
screenshots: string[];
/** URL final */
finalUrl: string;
/** Análise automática dos problemas */
analysis: ProblemAnalysis;
}
/**
* Análise automática dos problemas encontrados
*/
export interface ProblemAnalysis {
/** Resumo geral */
summary: string;
/** Problemas identificados */
problems: IdentifiedProblem[];
/** Sugestões de correção */
suggestions: string[];
/** Severidade geral: passed, warning, failed */
severity: 'passed' | 'warning' | 'failed';
}
export interface IdentifiedProblem {
/** Tipo do problema */
type: 'js-error' | 'vue-warning' | 'react-error' | 'network-error' | 'assertion-failed' | 'timeout';
/** Descrição */
description: string;
/** Onde ocorreu */
location?: string;
/** Severidade */
severity: 'low' | 'medium' | 'high' | 'critical';
/** Possível causa */
possibleCause?: string;
/** Sugestão de fix */
suggestedFix?: string;
}
/**
* Resultado de validação rápida de página
*/
export interface PageValidationResult {
url: string;
/** Se a página carregou sem erros críticos */
success: boolean;
/** Título da página */
pageTitle: string;
/** Tempo de carregamento em ms */
loadTime: number;
/** Erros de console */
consoleErrors: CapturedLog[];
/** Warnings de console */
consoleWarnings: CapturedLog[];
/** Requisições com falha */
failedRequests: CapturedRequest[];
/** Screenshot da página */
screenshot: string;
/** Análise */
analysis: ProblemAnalysis;
}
Passo 3: Parser de Documentação
3.1 Criar src/context/docs-parser.ts
// src/context/docs-parser.ts
import { glob } from 'glob';
import { readFileSync } from 'fs';
import { join, basename } from 'path';
import matter from 'gray-matter';
import type { ProjectContext, ProjectModule, RouteInfo } from '../types/index.js';
/**
* Parser de documentação do projeto
* Lê arquivos .md da pasta docs e extrai informações estruturadas
*/
export class DocsParser {
private projectRoot: string;
private docsPath: string;
constructor(projectRoot: string, docsPath: string = 'docs') {
this.projectRoot = projectRoot;
this.docsPath = docsPath;
}
/**
* Carrega e processa toda a documentação do projeto
*/
async loadProjectContext(): Promise<ProjectContext> {
const docsFullPath = join(this.projectRoot, this.docsPath);
const mdFiles = await glob('**/*.md', { cwd: docsFullPath });
const modules: ProjectModule[] = [];
const entities: Set<string> = new Set();
const routes: RouteInfo[] = [];
let projectName = '';
let projectDescription = '';
for (const file of mdFiles) {
const fullPath = join(docsFullPath, file);
const content = readFileSync(fullPath, 'utf-8');
const { data: frontmatter, content: markdown } = matter(content);
// Extrair nome do projeto do README ou doc principal
if (file.toLowerCase() === 'readme.md' || file.toLowerCase() === 'index.md') {
projectName = this.extractProjectName(markdown) || frontmatter.title || 'Projeto';
projectDescription = this.extractDescription(markdown) || frontmatter.description || '';
}
// Identificar módulos
const moduleInfo = this.extractModuleInfo(file, markdown, frontmatter);
if (moduleInfo) {
modules.push(moduleInfo);
}
// Extrair entidades mencionadas
const foundEntities = this.extractEntities(markdown);
foundEntities.forEach(e => entities.add(e));
// Extrair rotas
const foundRoutes = this.extractRoutes(markdown);
routes.push(...foundRoutes);
}
return {
name: projectName,
description: projectDescription,
modules: this.deduplicateModules(modules),
entities: Array.from(entities),
routes: this.deduplicateRoutes(routes),
docsProcessed: mdFiles,
loadedAt: new Date()
};
}
/**
* Gera um resumo formatado do contexto para o Claude
*/
formatContextSummary(context: ProjectContext): string {
let summary = `# Contexto do Projeto: ${context.name}\n\n`;
if (context.description) {
summary += `## Descrição\n${context.description}\n\n`;
}
if (context.modules.length > 0) {
summary += `## Módulos Identificados (${context.modules.length})\n\n`;
for (const mod of context.modules) {
summary += `### ${mod.name}\n`;
if (mod.description) {
summary += `${mod.description}\n`;
}
if (mod.features.length > 0) {
summary += `**Features:** ${mod.features.join(', ')}\n`;
}
summary += '\n';
}
}
if (context.entities.length > 0) {
summary += `## Entidades do Sistema\n`;
summary += context.entities.join(', ') + '\n\n';
}
if (context.routes.length > 0) {
summary += `## Rotas do Frontend (${context.routes.length})\n\n`;
const authRoutes = context.routes.filter(r => r.requiresAuth);
const publicRoutes = context.routes.filter(r => !r.requiresAuth);
if (publicRoutes.length > 0) {
summary += `**Rotas Públicas:**\n`;
for (const route of publicRoutes) {
summary += `- \`${route.path}\` - ${route.name}\n`;
}
summary += '\n';
}
if (authRoutes.length > 0) {
summary += `**Rotas Autenticadas:**\n`;
for (const route of authRoutes) {
summary += `- \`${route.path}\` - ${route.name}${route.module ? ` (${route.module})` : ''}\n`;
}
summary += '\n';
}
}
summary += `\n---\n`;
summary += `*Documentos processados: ${context.docsProcessed.length}*\n`;
summary += `*Carregado em: ${context.loadedAt.toISOString()}*\n`;
return summary;
}
// Métodos auxiliares de extração
private extractProjectName(markdown: string): string | null {
const h1Match = markdown.match(/^#\s+(.+)$/m);
return h1Match ? h1Match[1].trim() : null;
}
private extractDescription(markdown: string): string | null {
// Pega o primeiro parágrafo após o título
const lines = markdown.split('\n');
let foundTitle = false;
let description = '';
for (const line of lines) {
if (line.startsWith('# ')) {
foundTitle = true;
continue;
}
if (foundTitle && line.trim() && !line.startsWith('#')) {
description = line.trim();
break;
}
}
return description || null;
}
private extractModuleInfo(
filename: string,
markdown: string,
frontmatter: Record<string, any>
): ProjectModule | null {
// Ignora arquivos genéricos
const genericFiles = ['readme.md', 'index.md', 'changelog.md', 'contributing.md'];
if (genericFiles.includes(filename.toLowerCase())) {
return null;
}
const name = frontmatter.module ||
frontmatter.title ||
this.extractProjectName(markdown) ||
basename(filename, '.md');
const features: string[] = [];
// Extrai features de listas após "Features" ou "Funcionalidades"
const featureSection = markdown.match(/##\s*(Features|Funcionalidades|Recursos)\s*\n([\s\S]*?)(?=\n##|$)/i);
if (featureSection) {
const listItems = featureSection[2].match(/^[-*]\s+(.+)$/gm);
if (listItems) {
features.push(...listItems.map(item => item.replace(/^[-*]\s+/, '').trim()));
}
}
return {
name,
description: frontmatter.description || this.extractDescription(markdown) || '',
features,
relatedDocs: [filename]
};
}
private extractEntities(markdown: string): string[] {
const entities: string[] = [];
// Padrões comuns para identificar entidades
const patterns = [
/(?:model|entidade|entity|tabela|table):\s*`?(\w+)`?/gi,
/##\s*(?:Model|Entidade|Entity):\s*(\w+)/gi,
/`(\w+)(?:Model|Entity|Table)`/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(markdown)) !== null) {
entities.push(match[1]);
}
}
// Entidades comuns em sistemas de licitação
const commonEntities = [
'Licitacao', 'Licitação', 'Proposta', 'Fornecedor',
'Produtor', 'Usuario', 'Usuário', 'Categoria',
'Item', 'Lance', 'Contrato', 'Documento'
];
for (const entity of commonEntities) {
if (markdown.includes(entity) && !entities.includes(entity)) {
entities.push(entity);
}
}
return [...new Set(entities)];
}
private extractRoutes(markdown: string): RouteInfo[] {
const routes: RouteInfo[] = [];
// Padrão: /rota/path ou `rota/path`
const routePatterns = [
/`(\/[\w\-\/\:\[\]]+)`/g,
/(?:rota|route|path|url):\s*`?(\/[\w\-\/\:\[\]]+)`?/gi
];
for (const pattern of routePatterns) {
let match;
while ((match = pattern.exec(markdown)) !== null) {
const path = match[1];
// Ignora rotas de API
if (path.startsWith('/api/')) continue;
routes.push({
path,
name: this.routePathToName(path),
requiresAuth: this.guessRequiresAuth(path, markdown),
module: this.guessRouteModule(path)
});
}
}
return routes;
}
private routePathToName(path: string): string {
return path
.split('/')
.filter(Boolean)
.map(segment => {
if (segment.startsWith(':') || segment.startsWith('[')) return '';
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.filter(Boolean)
.join(' > ') || 'Home';
}
private guessRequiresAuth(path: string, markdown: string): boolean {
const protectedPaths = ['dashboard', 'admin', 'painel', 'minha', 'meu', 'profile', 'configurac'];
const publicPaths = ['login', 'registro', 'cadastro', 'home', 'sobre', 'contato', 'public'];
const pathLower = path.toLowerCase();
if (publicPaths.some(p => pathLower.includes(p))) return false;
if (protectedPaths.some(p => pathLower.includes(p))) return true;
// Verifica contexto no markdown
const pathContext = markdown.substring(
Math.max(0, markdown.indexOf(path) - 100),
markdown.indexOf(path) + path.length + 100
).toLowerCase();
if (pathContext.includes('autenticad') || pathContext.includes('logado') || pathContext.includes('protected')) {
return true;
}
return false;
}
private guessRouteModule(path: string): string | undefined {
const segments = path.split('/').filter(Boolean);
if (segments.length > 0) {
const first = segments[0].toLowerCase();
const moduleMap: Record<string, string> = {
'licitacoes': 'Licitações',
'licitacao': 'Licitações',
'propostas': 'Propostas',
'proposta': 'Propostas',
'dashboard': 'Dashboard',
'admin': 'Administração',
'config': 'Configurações',
'relatorios': 'Relatórios',
'usuarios': 'Usuários'
};
return moduleMap[first];
}
return undefined;
}
private deduplicateModules(modules: ProjectModule[]): ProjectModule[] {
const map = new Map<string, ProjectModule>();
for (const mod of modules) {
const existing = map.get(mod.name);
if (existing) {
existing.features = [...new Set([...existing.features, ...mod.features])];
existing.relatedDocs = [...new Set([...existing.relatedDocs, ...mod.relatedDocs])];
} else {
map.set(mod.name, mod);
}
}
return Array.from(map.values());
}
private deduplicateRoutes(routes: RouteInfo[]): RouteInfo[] {
const map = new Map<string, RouteInfo>();
for (const route of routes) {
if (!map.has(route.path)) {
map.set(route.path, route);
}
}
return Array.from(map.values());
}
}
Passo 4: Gerenciador do Playwright
4.1 Criar src/playwright/browser-manager.ts
// src/playwright/browser-manager.ts
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import type { QAAgentConfig } from '../types/index.js';
/**
* Gerencia a instância do browser Playwright
* Singleton para reutilização entre testes
*/
export class BrowserManager {
private static instance: BrowserManager;
private browser: Browser | null = null;
private config: QAAgentConfig;
private constructor(config: QAAgentConfig) {
this.config = config;
}
static getInstance(config: QAAgentConfig): BrowserManager {
if (!BrowserManager.instance) {
BrowserManager.instance = new BrowserManager(config);
}
return BrowserManager.instance;
}
/**
* Obtém ou cria a instância do browser
*/
async getBrowser(): Promise<Browser> {
if (!this.browser || !this.browser.isConnected()) {
this.browser = await chromium.launch({
headless: this.config.headless,
args: [
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process'
]
});
}
return this.browser;
}
/**
* Cria um novo contexto de navegação
*/
async createContext(): Promise<BrowserContext> {
const browser = await this.getBrowser();
return await browser.newContext({
viewport: { width: 1920, height: 1080 },
ignoreHTTPSErrors: true,
locale: 'pt-BR',
timezoneId: 'America/Sao_Paulo'
});
}
/**
* Cria um novo contexto com autenticação
*/
async createAuthenticatedContext(
loginUrl: string,
credentials: { email: string; password: string }
): Promise<BrowserContext> {
const context = await this.createContext();
const page = await context.newPage();
try {
await page.goto(`${this.config.baseUrl}${loginUrl}`, {
waitUntil: 'networkidle',
timeout: this.config.defaultTimeout
});
// Tenta diferentes seletores comuns de login
const emailSelectors = [
'input[name="email"]',
'input[type="email"]',
'#email',
'[data-testid="email-input"]'
];
const passwordSelectors = [
'input[name="password"]',
'input[type="password"]',
'#password',
'[data-testid="password-input"]'
];
const submitSelectors = [
'button[type="submit"]',
'[data-testid="login-button"]',
'button:has-text("Entrar")',
'button:has-text("Login")'
];
// Encontra e preenche email
for (const selector of emailSelectors) {
const element = await page.$(selector);
if (element) {
await element.fill(credentials.email);
break;
}
}
// Encontra e preenche senha
for (const selector of passwordSelectors) {
const element = await page.$(selector);
if (element) {
await element.fill(credentials.password);
break;
}
}
// Clica em submit
for (const selector of submitSelectors) {
const element = await page.$(selector);
if (element) {
await element.click();
break;
}
}
// Aguarda navegação após login
await page.waitForNavigation({
waitUntil: 'networkidle',
timeout: this.config.defaultTimeout
}).catch(() => {
// Ignora timeout se já navegou
});
await page.close();
return context;
} catch (error) {
await context.close();
throw new Error(`Falha na autenticação: ${error}`);
}
}
/**
* Fecha o browser
*/
async close(): Promise<void> {
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
}
4.2 Criar src/playwright/page-analyzer.ts
// src/playwright/page-analyzer.ts
import { Page, ConsoleMessage, Request, Response } from 'playwright';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import type {
CapturedLog,
CapturedRequest,
ProblemAnalysis,
IdentifiedProblem
} from '../types/index.js';
/**
* Analisa páginas e captura logs, erros e métricas
*/
export class PageAnalyzer {
private consoleLogs: CapturedLog[] = [];
private networkRequests: CapturedRequest[] = [];
private screenshotsPath: string;
private screenshotCounter = 0;
constructor(screenshotsPath: string) {
this.screenshotsPath = screenshotsPath;
if (!existsSync(screenshotsPath)) {
mkdirSync(screenshotsPath, { recursive: true });
}
}
/**
* Anexa os listeners de captura à página
*/
attachListeners(page: Page): void {
// Captura logs do console
page.on('console', (msg: ConsoleMessage) => {
this.consoleLogs.push({
type: this.mapConsoleType(msg.type()),
message: msg.text(),
url: page.url(),
location: msg.location(),
timestamp: new Date()
});
});
// Captura erros de página (JS errors)
page.on('pageerror', (error: Error) => {
this.consoleLogs.push({
type: 'error',
message: error.message,
url: page.url(),
timestamp: new Date(),
stack: error.stack
});
});
// Captura requisições
page.on('requestfinished', async (request: Request) => {
const response = await request.response();
const timing = request.timing();
this.networkRequests.push({
url: request.url(),
method: request.method(),
status: response?.status(),
statusText: response?.statusText(),
failed: false,
timing: timing.responseEnd - timing.requestStart,
resourceType: request.resourceType(),
timestamp: new Date()
});
});
// Captura requisições que falharam
page.on('requestfailed', (request: Request) => {
this.networkRequests.push({
url: request.url(),
method: request.method(),
failed: true,
failureText: request.failure()?.errorText || 'Unknown error',
resourceType: request.resourceType(),
timestamp: new Date()
});
});
}
/**
* Tira screenshot e salva
*/
async takeScreenshot(page: Page, name: string): Promise<string> {
this.screenshotCounter++;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${this.screenshotCounter.toString().padStart(3, '0')}-${name}-${timestamp}.png`;
const filepath = join(this.screenshotsPath, filename);
await page.screenshot({
path: filepath,
fullPage: false
});
return filepath;
}
/**
* Retorna logs capturados
*/
getLogs(): CapturedLog[] {
return [...this.consoleLogs];
}
/**
* Retorna erros de console
*/
getErrors(): CapturedLog[] {
return this.consoleLogs.filter(log => log.type === 'error');
}
/**
* Retorna warnings de console
*/
getWarnings(): CapturedLog[] {
return this.consoleLogs.filter(log => log.type === 'warn');
}
/**
* Retorna requisições capturadas
*/
getRequests(): CapturedRequest[] {
return [...this.networkRequests];
}
/**
* Retorna requisições que falharam
*/
getFailedRequests(): CapturedRequest[] {
return this.networkRequests.filter(req => req.failed || (req.status && req.status >= 400));
}
/**
* Limpa os dados capturados
*/
clear(): void {
this.consoleLogs = [];
this.networkRequests = [];
this.screenshotCounter = 0;
}
/**
* Analisa os problemas encontrados
*/
analyzeProblems(): ProblemAnalysis {
const problems: IdentifiedProblem[] = [];
const suggestions: string[] = [];
// Analisa erros de console
for (const error of this.getErrors()) {
const problem = this.categorizeError(error);
if (problem) {
problems.push(problem);
}
}
// Analisa warnings
for (const warning of this.getWarnings()) {
const problem = this.categorizeWarning(warning);
if (problem) {
problems.push(problem);
}
}
// Analisa requisições com falha
for (const request of this.getFailedRequests()) {
problems.push(this.categorizeNetworkError(request));
}
// Gera sugestões
suggestions.push(...this.generateSuggestions(problems));
// Determina severidade geral
const hasCritical = problems.some(p => p.severity === 'critical');
const hasHigh = problems.some(p => p.severity === 'high');
const hasErrors = this.getErrors().length > 0 || this.getFailedRequests().length > 0;
let severity: 'passed' | 'warning' | 'failed';
if (hasCritical || hasHigh || hasErrors) {
severity = 'failed';
} else if (problems.length > 0) {
severity = 'warning';
} else {
severity = 'passed';
}
return {
summary: this.generateSummary(problems, severity),
problems,
suggestions,
severity
};
}
// Métodos auxiliares
private mapConsoleType(type: string): CapturedLog['type'] {
const map: Record<string, CapturedLog['type']> = {
'log': 'log',
'warning': 'warn',
'error': 'error',
'info': 'info',
'debug': 'debug'
};
return map[type] || 'log';
}
private categorizeError(log: CapturedLog): IdentifiedProblem | null {
const message = log.message.toLowerCase();
// Vue warnings/errors
if (message.includes('[vue warn]') || message.includes('vue')) {
return {
type: 'vue-warning',
description: log.message,
location: log.location?.url || log.url,
severity: 'high',
possibleCause: this.guessVueCause(log.message),
suggestedFix: this.suggestVueFix(log.message)
};
}
// React errors
if (message.includes('react') || message.includes('cannot read prop')) {
return {
type: 'react-error',
description: log.message,
location: log.location?.url || log.url,
severity: 'high',
possibleCause: 'Tentativa de acessar propriedade de objeto null/undefined',
suggestedFix: 'Adicionar verificação de null/undefined ou optional chaining (?.) antes do acesso'
};
}
// Erro JS genérico
return {
type: 'js-error',
description: log.message,
location: log.location?.url || log.url,
severity: 'high',
possibleCause: this.guessJsErrorCause(log.message),
suggestedFix: this.suggestJsErrorFix(log.message)
};
}
private categorizeWarning(log: CapturedLog): IdentifiedProblem | null {
const message = log.message.toLowerCase();
// Vue deprecation ou warning
if (message.includes('vue') || message.includes('deprecat')) {
return {
type: 'vue-warning',
description: log.message,
severity: 'medium',
possibleCause: 'API deprecated ou uso incorreto de feature Vue'
};
}
// Performance warning
if (message.includes('performance') || message.includes('slow')) {
return {
type: 'js-error',
description: log.message,
severity: 'low',
possibleCause: 'Problema de performance detectado pelo browser'
};
}
return null;
}
private categorizeNetworkError(request: CapturedRequest): IdentifiedProblem {
let severity: IdentifiedProblem['severity'] = 'high';
let possibleCause = '';
let suggestedFix = '';
if (request.status === 401) {
possibleCause = 'Token de autenticação ausente ou expirado';
suggestedFix = 'Verificar se o token está sendo enviado no header Authorization';
} else if (request.status === 403) {
possibleCause = 'Usuário não tem permissão para este recurso';
suggestedFix = 'Verificar roles/permissions do usuário';
} else if (request.status === 404) {
possibleCause = 'Endpoint não existe ou URL incorreta';
suggestedFix = 'Verificar se a rota está definida no backend e se a URL está correta';
} else if (request.status === 422) {
possibleCause = 'Dados enviados inválidos (validação falhou)';
suggestedFix = 'Verificar o formato dos dados enviados na requisição';
severity = 'medium';
} else if (request.status === 500) {
possibleCause = 'Erro interno no servidor';
suggestedFix = 'Verificar logs do backend para identificar o erro';
severity = 'critical';
} else if (request.failed) {
if (request.failureText?.includes('CORS')) {
possibleCause = 'Bloqueio por política CORS';
suggestedFix = 'Configurar headers CORS no backend para permitir a origem do frontend';
} else if (request.failureText?.includes('net::ERR')) {
possibleCause = 'Falha de conexão de rede';
suggestedFix = 'Verificar se o servidor backend está rodando e acessível';
}
}
return {
type: 'network-error',
description: `${request.method} ${request.url} - ${request.status || request.failureText}`,
severity,
possibleCause,
suggestedFix
};
}
private guessVueCause(message: string): string {
if (message.includes('Invalid prop')) {
return 'Prop recebendo tipo diferente do esperado';
}
if (message.includes('not defined')) {
return 'Variável ou método não definido no componente';
}
if (message.includes('reactive')) {
return 'Problema com reatividade Vue';
}
return 'Erro no componente Vue';
}
private suggestVueFix(message: string): string {
if (message.includes('Invalid prop')) {
return 'Verificar o tipo da prop no componente pai e adicionar validação ou valor default';
}
if (message.includes('not defined')) {
return 'Declarar a variável/método no setup() ou data()';
}
return 'Revisar o código do componente mencionado no erro';
}
private guessJsErrorCause(message: string): string {
if (message.includes('undefined') || message.includes('null')) {
return 'Tentativa de acessar propriedade de valor null/undefined';
}
if (message.includes('not a function')) {
return 'Tentativa de chamar como função algo que não é função';
}
if (message.includes('not defined')) {
return 'Variável não declarada ou fora do escopo';
}
return 'Erro de JavaScript em runtime';
}
private suggestJsErrorFix(message: string): string {
if (message.includes('undefined') || message.includes('null')) {
return 'Usar optional chaining (?.) ou verificar se o valor existe antes de acessar';
}
if (message.includes('not a function')) {
return 'Verificar se o método existe e está sendo importado corretamente';
}
return 'Analisar o stack trace para identificar a linha com problema';
}
private generateSuggestions(problems: IdentifiedProblem[]): string[] {
const suggestions: string[] = [];
const hasNetworkErrors = problems.some(p => p.type === 'network-error');
const hasJsErrors = problems.some(p => p.type === 'js-error');
const hasVueWarnings = problems.some(p => p.type === 'vue-warning');
if (hasNetworkErrors) {
suggestions.push('Verificar se o backend está rodando e acessível');
suggestions.push('Conferir se as variáveis de ambiente de API estão configuradas');
}
if (hasJsErrors) {
suggestions.push('Revisar os componentes mencionados nos erros');
suggestions.push('Adicionar tratamento de erros (try/catch) em operações assíncronas');
}
if (hasVueWarnings) {
suggestions.push('Revisar props e suas tipagens nos componentes afetados');
suggestions.push('Verificar se todos os dados reativos estão declarados corretamente');
}
// Remove sugestões duplicadas dos problemas individuais
const uniqueSuggestions = [...new Set(suggestions)];
return uniqueSuggestions;
}
private generateSummary(problems: IdentifiedProblem[], severity: 'passed' | 'warning' | 'failed'): string {
if (severity === 'passed') {
return 'Nenhum problema detectado. A página está funcionando corretamente.';
}
const errorCount = problems.filter(p => p.severity === 'high' || p.severity === 'critical').length;
const warningCount = problems.filter(p => p.severity === 'medium' || p.severity === 'low').length;
if (severity === 'failed') {
return `Encontrados ${errorCount} erro(s) crítico(s) que precisam ser corrigidos${warningCount > 0 ? ` e ${warningCount} warning(s)` : ''}.`;
}
return `Encontrados ${warningCount} warning(s) que podem ser melhorados.`;
}
}
Passo 5: Tools do MCP
5.1 Criar src/tools/load-context.ts
// src/tools/load-context.ts
import { DocsParser } from '../context/docs-parser.js';
import type { QAAgentConfig, ProjectContext } from '../types/index.js';
let cachedContext: ProjectContext | null = null;
/**
* Carrega o contexto do projeto a partir da documentação
*/
export async function loadProjectContext(config: QAAgentConfig): Promise<{
success: boolean;
context?: ProjectContext;
summary?: string;
error?: string;
}> {
try {
const parser = new DocsParser(config.projectRoot, config.docsPath);
const context = await parser.loadProjectContext();
// Cache para uso em outras tools
cachedContext = context;
const summary = parser.formatContextSummary(context);
return {
success: true,
context,
summary
};
} catch (error) {
return {
success: false,
error: `Erro ao carregar contexto: ${error}`
};
}
}
/**
* Retorna o contexto em cache
*/
export function getCachedContext(): ProjectContext | null {
return cachedContext;
}
5.2 Criar src/tools/validate-page.ts
// src/tools/validate-page.ts
import { BrowserManager } from '../playwright/browser-manager.js';
import { PageAnalyzer } from '../playwright/page-analyzer.js';
import type { QAAgentConfig, PageValidationResult } from '../types/index.js';
/**
* Valida uma página específica - teste rápido sem fluxo
*/
export async function validatePage(
config: QAAgentConfig,
url: string,
options: {
waitForSelector?: string;
timeout?: number;
authenticate?: {
loginUrl: string;
email: string;
password: string;
};
} = {}
): Promise<PageValidationResult> {
const browserManager = BrowserManager.getInstance(config);
const analyzer = new PageAnalyzer(config.screenshotsPath);
const fullUrl = url.startsWith('http') ? url : `${config.baseUrl}${url}`;
const timeout = options.timeout || config.defaultTimeout;
let context;
try {
// Cria contexto (autenticado ou não)
if (options.authenticate) {
context = await browserManager.createAuthenticatedContext(
options.authenticate.loginUrl,
{
email: options.authenticate.email,
password: options.authenticate.password
}
);
} else {
context = await browserManager.createContext();
}
const page = await context.newPage();
analyzer.attachListeners(page);
const startTime = Date.now();
// Navega para a página
await page.goto(fullUrl, {
waitUntil: 'networkidle',
timeout
});
// Espera seletor específico se fornecido
if (options.waitForSelector) {
await page.waitForSelector(options.waitForSelector, { timeout });
}
// Pequena pausa para capturar logs atrasados
await page.waitForTimeout(1000);
const loadTime = Date.now() - startTime;
const pageTitle = await page.title();
// Tira screenshot
const screenshot = await analyzer.takeScreenshot(page, 'validation');
// Analisa problemas
const analysis = analyzer.analyzeProblems();
await context.close();
return {
url: fullUrl,
success: analysis.severity !== 'failed',
pageTitle,
loadTime,
consoleErrors: analyzer.getErrors(),
consoleWarnings: analyzer.getWarnings(),
failedRequests: analyzer.getFailedRequests(),
screenshot,
analysis
};
} catch (error) {
if (context) {
await context.close();
}
return {
url: fullUrl,
success: false,
pageTitle: '',
loadTime: 0,
consoleErrors: [{
type: 'error',
message: `Erro ao carregar página: ${error}`,
url: fullUrl,
timestamp: new Date()
}],
consoleWarnings: [],
failedRequests: [],
screenshot: '',
analysis: {
summary: `Falha crítica ao carregar a página: ${error}`,
problems: [{
type: 'js-error',
description: `${error}`,
severity: 'critical'
}],
suggestions: ['Verificar se a URL está correta', 'Verificar se o servidor está rodando'],
severity: 'failed'
}
};
}
}
/**
* Formata o resultado para exibição
*/
export function formatValidationResult(result: PageValidationResult): string {
const statusIcon = result.success ? '✅' : '❌';
let output = `${statusIcon} Validação de Página\n\n`;
output += `**URL:** ${result.url}\n`;
output += `**Título:** ${result.pageTitle}\n`;
output += `**Tempo de carregamento:** ${result.loadTime}ms\n\n`;
if (result.consoleErrors.length > 0) {
output += `### 🔴 Erros de Console (${result.consoleErrors.length})\n\n`;
for (const error of result.consoleErrors) {
output += `- \`${error.message}\`\n`;
if (error.location) {
output += ` 📍 ${error.location.url}:${error.location.lineNumber}\n`;
}
}
output += '\n';
}
if (result.consoleWarnings.length > 0) {
output += `### 🟡 Warnings (${result.consoleWarnings.length})\n\n`;
for (const warning of result.consoleWarnings) {
output += `- \`${warning.message}\`\n`;
}
output += '\n';
}
if (result.failedRequests.length > 0) {
output += `### 🔴 Requisições com Falha (${result.failedRequests.length})\n\n`;
for (const req of result.failedRequests) {
output += `- \`${req.method} ${req.url}\` → ${req.status || req.failureText}\n`;
}
output += '\n';
}
output += `### 📋 Análise\n\n`;
output += `**Severidade:** ${result.analysis.severity}\n`;
output += `**Resumo:** ${result.analysis.summary}\n\n`;
if (result.analysis.problems.length > 0) {
output += `**Problemas Identificados:**\n`;
for (const problem of result.analysis.problems) {
output += `- [${problem.severity.toUpperCase()}] ${problem.description}\n`;
if (problem.possibleCause) {
output += ` 💡 Causa: ${problem.possibleCause}\n`;
}
if (problem.suggestedFix) {
output += ` 🔧 Fix: ${problem.suggestedFix}\n`;
}
}
output += '\n';
}
if (result.analysis.suggestions.length > 0) {
output += `**Sugestões Gerais:**\n`;
for (const suggestion of result.analysis.suggestions) {
output += `- ${suggestion}\n`;
}
output += '\n';
}
if (result.screenshot) {
output += `📸 Screenshot: \`${result.screenshot}\`\n`;
}
return output;
}
5.3 Criar src/tools/run-flow.ts
// src/tools/run-flow.ts
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { BrowserManager } from '../playwright/browser-manager.js';
import { PageAnalyzer } from '../playwright/page-analyzer.js';
import type {
QAAgentConfig,
TestFlow,
TestStep,
FlowResult,
StepResult
} from '../types/index.js';
/**
* Executa um fluxo de teste completo
*/
export async function runTestFlow(
config: QAAgentConfig,
flowIdOrPath: string
): Promise<FlowResult> {
// Carrega o fluxo
const flow = loadFlow(config, flowIdOrPath);
if (!flow) {
throw new Error(`Fluxo não encontrado: ${flowIdOrPath}`);
}
const browserManager = BrowserManager.getInstance(config);
const analyzer = new PageAnalyzer(config.screenshotsPath);
const startedAt = new Date();
const stepResults: StepResult[] = [];
const screenshots: string[] = [];
let context;
let page;
let success = true;
let finalUrl = '';
try {
// Cria contexto (autenticado ou não)
if (flow.requiresAuth && flow.credentials) {
context = await browserManager.createAuthenticatedContext(
'/login',
flow.credentials
);
} else {
context = await browserManager.createContext();
}
page = await context.newPage();
analyzer.attachListeners(page);
// Navega para URL inicial
const startUrl = flow.startUrl.startsWith('http')
? flow.startUrl
: `${config.baseUrl}${flow.startUrl}`;
await page.goto(startUrl, {
waitUntil: 'networkidle',
timeout: config.defaultTimeout
});
// Executa cada step
for (const step of flow.steps) {
const stepStartTime = Date.now();
let stepSuccess = true;
let stepError: string | undefined;
let stepScreenshot: string | undefined;
try {
await executeStep(page, step, config);
// Screenshot se configurado
if (step.screenshot !== false) {
stepScreenshot = await analyzer.takeScreenshot(page, step.name.replace(/\s+/g, '-'));
screenshots.push(stepScreenshot);
}
} catch (error) {
stepSuccess = false;
stepError = `${error}`;
// Screenshot do erro
stepScreenshot = await analyzer.takeScreenshot(page, `error-${step.name.replace(/\s+/g, '-')}`);
screenshots.push(stepScreenshot);
if (!step.optional) {
success = false;
// Registra step e interrompe
stepResults.push({
name: step.name,
success: false,
duration: Date.now() - stepStartTime,
error: stepError,
screenshot: stepScreenshot,
skipped: false
});
// Marca restantes como pulados
const currentIndex = flow.steps.indexOf(step);
for (let i = currentIndex + 1; i < flow.steps.length; i++) {
stepResults.push({
name: flow.steps[i].name,
success: false,
duration: 0,
skipped: true
});
}
break;
}
}
stepResults.push({
name: step.name,
success: stepSuccess,
duration: Date.now() - stepStartTime,
error: stepError,
screenshot: stepScreenshot,
skipped: false
});
}
finalUrl = page.url();
await context.close();
} catch (error) {
success = false;
if (context) {
await context.close();
}
}
const finishedAt = new Date();
const analysis = analyzer.analyzeProblems();
// Atualiza success baseado na análise
if (analysis.severity === 'failed') {
success = false;
}
return {
flowId: flow.id,
flowName: flow.name,
success,
startedAt,
finishedAt,
totalDuration: finishedAt.getTime() - startedAt.getTime(),
steps: stepResults,
passedSteps: stepResults.filter(s => s.success && !s.skipped).length,
failedSteps: stepResults.filter(s => !s.success && !s.skipped).length,
skippedSteps: stepResults.filter(s => s.skipped).length,
consoleLogs: analyzer.getLogs(),
consoleErrors: analyzer.getErrors(),
consoleWarnings: analyzer.getWarnings(),
networkRequests: analyzer.getRequests(),
failedRequests: analyzer.getFailedRequests(),
screenshots,
finalUrl,
analysis
};
}
/**
* Carrega um fluxo do arquivo YAML
*/
function loadFlow(config: QAAgentConfig, flowIdOrPath: string): TestFlow | null {
// Tenta como caminho direto
if (existsSync(flowIdOrPath)) {
const content = readFileSync(flowIdOrPath, 'utf-8');
return parseYaml(content) as TestFlow;
}
// Tenta na pasta de flows
const flowPath = join(config.flowsPath, `${flowIdOrPath}.yaml`);
if (existsSync(flowPath)) {
const content = readFileSync(flowPath, 'utf-8');
const parsed = parseYaml(content) as TestFlow;
parsed.id = flowIdOrPath;
return parsed;
}
// Tenta com extensão .yml
const flowPathYml = join(config.flowsPath, `${flowIdOrPath}.yml`);
if (existsSync(flowPathYml)) {
const content = readFileSync(flowPathYml, 'utf-8');
const parsed = parseYaml(content) as TestFlow;
parsed.id = flowIdOrPath;
return parsed;
}
return null;
}
/**
* Executa um step individual
*/
async function executeStep(
page: any,
step: TestStep,
config: QAAgentConfig
): Promise<void> {
const timeout = step.timeout || config.defaultTimeout;
switch (step.action) {
case 'navigate':
const url = step.url!.startsWith('http') ? step.url! : `${config.baseUrl}${step.url!}`;
await page.goto(url, { waitUntil: 'networkidle', timeout });
break;
case 'click':
await page.click(step.selector!, { timeout });
break;
case 'fill':
await page.fill(step.selector!, step.value!, { timeout });
break;
case 'fill-form':
if (step.fields) {
for (const [field, value] of Object.entries(step.fields)) {
// Tenta diferentes padrões de seletor
const selectors = [
`[name="${field}"]`,
`#${field}`,
`[data-testid="${field}"]`,
`[data-testid="${field}-input"]`
];
let filled = false;
for (const selector of selectors) {
const element = await page.$(selector);
if (element) {
await element.fill(processValue(value));
filled = true;
break;
}
}
if (!filled) {
throw new Error(`Campo não encontrado: ${field}`);
}
}
}
break;
case 'select':
await page.selectOption(step.selector!, step.value!, { timeout });
break;
case 'check':
await page.check(step.selector!, { timeout });
break;
case 'uncheck':
await page.uncheck(step.selector!, { timeout });
break;
case 'wait':
await page.waitForSelector(step.selector!, { timeout });
break;
case 'wait-url':
await page.waitForURL(step.url!, { timeout });
break;
case 'assert-visible':
await page.waitForSelector(step.selector!, { state: 'visible', timeout });
break;
case 'assert-hidden':
await page.waitForSelector(step.selector!, { state: 'hidden', timeout });
break;
case 'assert-text':
const element = await page.waitForSelector(step.selector!, { timeout });
const text = await element.textContent();
if (!text?.includes(step.value!)) {
throw new Error(`Texto "${step.value}" não encontrado. Texto atual: "${text}"`);
}
break;
case 'assert-url':
const currentUrl = page.url();
if (!currentUrl.includes(step.url!)) {
throw new Error(`URL esperada: "${step.url}", URL atual: "${currentUrl}"`);
}
break;
case 'screenshot':
// Screenshot é tratado no nível do fluxo
break;
case 'scroll':
if (step.selector) {
await page.locator(step.selector).scrollIntoViewIfNeeded();
} else {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
}
break;
case 'hover':
await page.hover(step.selector!, { timeout });
break;
case 'press-key':
await page.keyboard.press(step.value!);
break;
default:
throw new Error(`Ação não suportada: ${step.action}`);
}
// Pausa para estabilização
await page.waitForTimeout(300);
}
/**
* Processa valores especiais (datas relativas, etc)
*/
function processValue(value: string): string {
if (value === '+7days') {
const date = new Date();
date.setDate(date.getDate() + 7);
return date.toISOString().split('T')[0];
}
if (value === '+30days') {
const date = new Date();
date.setDate(date.getDate() + 30);
return date.toISOString().split('T')[0];
}
if (value === 'today') {
return new Date().toISOString().split('T')[0];
}
return value;
}
/**
* Formata o resultado do fluxo para exibição
*/
export function formatFlowResult(result: FlowResult): string {
const statusIcon = result.success ? '✅' : '❌';
let output = `${statusIcon} Fluxo "${result.flowName}" ${result.success ? 'PASSOU' : 'FALHOU'}\n\n`;
output += `**Duração total:** ${result.totalDuration}ms\n`;
output += `**Steps:** ${result.passedSteps} passou | ${result.failedSteps} falhou | ${result.skippedSteps} pulado\n\n`;
output += `### 📋 Steps Executados\n\n`;
for (const step of result.steps) {
const icon = step.skipped ? '⏭️' : (step.success ? '✅' : '❌');
output += `${icon} **${step.name}**`;
if (!step.skipped) {
output += ` (${step.duration}ms)`;
}
if (step.error) {
output += `\n ⚠️ ${step.error}`;
}
output += '\n';
}
output += '\n';
if (result.consoleErrors.length > 0) {
output += `### 🔴 Erros de Console (${result.consoleErrors.length})\n\n`;
for (const error of result.consoleErrors.slice(0, 10)) {
output += `- \`${error.message.substring(0, 200)}\`\n`;
if (error.location) {
output += ` 📍 ${error.location.url}:${error.location.lineNumber}\n`;
}
}
if (result.consoleErrors.length > 10) {
output += `\n... e mais ${result.consoleErrors.length - 10} erros\n`;
}
output += '\n';
}
if (result.consoleWarnings.length > 0) {
output += `### 🟡 Warnings (${result.consoleWarnings.length})\n\n`;
for (const warning of result.consoleWarnings.slice(0, 5)) {
output += `- \`${warning.message.substring(0, 200)}\`\n`;
}
if (result.consoleWarnings.length > 5) {
output += `\n... e mais ${result.consoleWarnings.length - 5} warnings\n`;
}
output += '\n';
}
if (result.failedRequests.length > 0) {
output += `### 🔴 Requisições com Falha (${result.failedRequests.length})\n\n`;
for (const req of result.failedRequests) {
output += `- \`${req.method} ${req.url}\` → ${req.status || req.failureText}\n`;
}
output += '\n';
}
output += `### 📋 Análise\n\n`;
output += `**Severidade:** ${result.analysis.severity}\n`;
output += `**Resumo:** ${result.analysis.summary}\n\n`;
if (result.analysis.problems.length > 0) {
output += `**Problemas Identificados:**\n`;
for (const problem of result.analysis.problems) {
output += `- [${problem.severity.toUpperCase()}] ${problem.description}\n`;
if (problem.possibleCause) {
output += ` 💡 Causa: ${problem.possibleCause}\n`;
}
if (problem.suggestedFix) {
output += ` 🔧 Fix: ${problem.suggestedFix}\n`;
}
}
output += '\n';
}
if (result.analysis.suggestions.length > 0) {
output += `**Sugestões Gerais:**\n`;
for (const suggestion of result.analysis.suggestions) {
output += `- ${suggestion}\n`;
}
output += '\n';
}
output += `**URL Final:** ${result.finalUrl}\n\n`;
if (result.screenshots.length > 0) {
output += `### 📸 Screenshots\n`;
for (const ss of result.screenshots) {
output += `- \`${ss}\`\n`;
}
}
return output;
}
5.4 Criar src/tools/manage-flows.ts
// src/tools/manage-flows.ts
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type { QAAgentConfig, TestFlow } from '../types/index.js';
/**
* Lista todos os fluxos disponíveis
*/
export function listFlows(config: QAAgentConfig): {
flows: Array<{
id: string;
name: string;
description: string;
module?: string;
requiresAuth: boolean;
stepsCount: number;
}>;
} {
const flowsDir = config.flowsPath;
if (!existsSync(flowsDir)) {
return { flows: [] };
}
const files = readdirSync(flowsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
const flows = files
.filter(f => !f.startsWith('_')) // Ignora templates
.map(file => {
const content = readFileSync(join(flowsDir, file), 'utf-8');
const flow = parseYaml(content) as TestFlow;
const id = basename(file, file.endsWith('.yaml') ? '.yaml' : '.yml');
return {
id,
name: flow.name || id,
description: flow.description || '',
module: flow.module,
requiresAuth: flow.requiresAuth || false,
stepsCount: flow.steps?.length || 0
};
});
return { flows };
}
/**
* Retorna detalhes de um fluxo específico
*/
export function getFlowDetails(config: QAAgentConfig, flowId: string): TestFlow | null {
const yamlPath = join(config.flowsPath, `${flowId}.yaml`);
const ymlPath = join(config.flowsPath, `${flowId}.yml`);
const path = existsSync(yamlPath) ? yamlPath : (existsSync(ymlPath) ? ymlPath : null);
if (!path) {
return null;
}
const content = readFileSync(path, 'utf-8');
const flow = parseYaml(content) as TestFlow;
flow.id = flowId;
return flow;
}
/**
* Cria um novo fluxo de teste
*/
export function createFlow(
config: QAAgentConfig,
flow: Omit<TestFlow, 'id'>,
flowId: string
): { success: boolean; path?: string; error?: string } {
const flowPath = join(config.flowsPath, `${flowId}.yaml`);
if (existsSync(flowPath)) {
return {
success: false,
error: `Fluxo "${flowId}" já existe`
};
}
try {
const yaml = stringifyYaml(flow, {
lineWidth: 0,
defaultKeyType: 'PLAIN',
defaultStringType: 'QUOTE_DOUBLE'
});
writeFileSync(flowPath, yaml, 'utf-8');
return {
success: true,
path: flowPath
};
} catch (error) {
return {
success: false,
error: `Erro ao criar fluxo: ${error}`
};
}
}
/**
* Formata lista de fluxos para exibição
*/
export function formatFlowsList(flows: ReturnType<typeof listFlows>): string {
if (flows.flows.length === 0) {
return 'Nenhum fluxo de teste encontrado.\n\nCrie fluxos na pasta `flows/` ou use `qa_create_flow` para criar um novo.';
}
let output = `### 📋 Fluxos de Teste Disponíveis (${flows.flows.length})\n\n`;
// Agrupa por módulo
const byModule = new Map<string, typeof flows.flows>();
for (const flow of flows.flows) {
const module = flow.module || 'Geral';
if (!byModule.has(module)) {
byModule.set(module, []);
}
byModule.get(module)!.push(flow);
}
for (const [module, moduleFlows] of byModule) {
output += `**${module}**\n`;
for (const flow of moduleFlows) {
const authIcon = flow.requiresAuth ? '🔐' : '🌐';
output += `- ${authIcon} \`${flow.id}\` - ${flow.name} (${flow.stepsCount} steps)\n`;
if (flow.description) {
output += ` ${flow.description}\n`;
}
}
output += '\n';
}
return output;
}
5.5 Criar src/tools/index.ts
// src/tools/index.ts
export { loadProjectContext, getCachedContext } from './load-context.js';
export { validatePage, formatValidationResult } from './validate-page.js';
export { runTestFlow, formatFlowResult } from './run-flow.js';
export { listFlows, getFlowDetails, createFlow, formatFlowsList } from './manage-flows.js';
Passo 6: MCP Server
6.1 Criar src/server.ts
// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool
} from '@modelcontextprotocol/sdk/types.js';
import type { QAAgentConfig } from './types/index.js';
import {
loadProjectContext,
validatePage,
formatValidationResult,
runTestFlow,
formatFlowResult,
listFlows,
formatFlowsList,
getFlowDetails,
createFlow
} from './tools/index.js';
/**
* Cria e configura o MCP Server
*/
export function createMCPServer(config: QAAgentConfig): Server {
const server = new Server(
{
name: 'qa-agent',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
// Define as tools disponíveis
const tools: Tool[] = [
{
name: 'qa_load_project_context',
description: `Carrega e analisa a documentação do projeto para entender a estrutura, módulos, entidades e rotas.
Use esta ferramenta PRIMEIRO antes de executar testes para entender o contexto do projeto.
Lê arquivos .md da pasta docs/ e retorna um resumo estruturado.`,
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'qa_validate_page',
description: `Valida uma página específica do frontend.
Abre a URL no navegador, captura erros de console, falhas de rede e tira screenshot.
Use para validação rápida de uma página sem executar um fluxo completo.`,
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL da página (relativa ao baseUrl ou absoluta)'
},
waitForSelector: {
type: 'string',
description: 'Seletor CSS para aguardar antes de capturar (opcional)'
},
authenticate: {
type: 'object',
description: 'Credenciais para autenticação (opcional)',
properties: {
loginUrl: {
type: 'string',
description: 'URL da página de login (ex: /login)'
},
email: {
type: 'string',
description: 'Email para login'
},
password: {
type: 'string',
description: 'Senha para login'
}
},
required: ['loginUrl', 'email', 'password']
}
},
required: ['url']
}
},
{
name: 'qa_run_flow',
description: `Executa um fluxo de teste completo definido em YAML.
O fluxo contém uma sequência de passos (navegação, cliques, preenchimento de forms, assertions).
Captura todos os erros de console, falhas de rede e screenshots durante a execução.
Retorna análise detalhada dos problemas encontrados com sugestões de correção.`,
inputSchema: {
type: 'object',
properties: {
flowId: {
type: 'string',
description: 'ID do fluxo (nome do arquivo sem extensão) ou caminho completo para o arquivo YAML'
}
},
required: ['flowId']
}
},
{
name: 'qa_list_flows',
description: `Lista todos os fluxos de teste disponíveis.
Mostra ID, nome, descrição, módulo e número de steps de cada fluxo.`,
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'qa_get_flow_details',
description: `Retorna os detalhes completos de um fluxo específico.
Inclui todos os steps, precondições e validações.`,
inputSchema: {
type: 'object',
properties: {
flowId: {
type: 'string',
description: 'ID do fluxo'
}
},
required: ['flowId']
}
},
{
name: 'qa_create_flow',
description: `Cria um novo fluxo de teste.
Use para criar fluxos para novas features ou páginas.`,
inputSchema: {
type: 'object',
properties: {
flowId: {
type: 'string',
description: 'ID único para o fluxo (será o nome do arquivo)'
},
name: {
type: 'string',
description: 'Nome legível do fluxo'
},
description: {
type: 'string',
description: 'Descrição do que o fluxo testa'
},
module: {
type: 'string',
description: 'Módulo relacionado (ex: Licitações, Propostas)'
},
requiresAuth: {
type: 'boolean',
description: 'Se o fluxo requer autenticação'
},
startUrl: {
type: 'string',
description: 'URL inicial do fluxo'
},
steps: {
type: 'array',
description: 'Passos do teste',
items: {
type: 'object',
properties: {
name: { type: 'string' },
action: {
type: 'string',
enum: ['navigate', 'click', 'fill', 'fill-form', 'select', 'check', 'uncheck',
'wait', 'wait-url', 'assert-visible', 'assert-hidden', 'assert-text',
'assert-url', 'screenshot', 'scroll', 'hover', 'press-key']
},
selector: { type: 'string' },
url: { type: 'string' },
value: { type: 'string' },
fields: { type: 'object' }
},
required: ['name', 'action']
}
}
},
required: ['flowId', 'name', 'startUrl', 'steps']
}
}
];
// Handler para listar tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
// Handler para executar tools
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'qa_load_project_context': {
const result = await loadProjectContext(config);
if (result.success) {
return {
content: [{ type: 'text', text: result.summary! }]
};
} else {
return {
content: [{ type: 'text', text: `Erro: ${result.error}` }],
isError: true
};
}
}
case 'qa_validate_page': {
const { url, waitForSelector, authenticate } = args as any;
const result = await validatePage(config, url, {
waitForSelector,
authenticate
});
return {
content: [{ type: 'text', text: formatValidationResult(result) }]
};
}
case 'qa_run_flow': {
const { flowId } = args as any;
const result = await runTestFlow(config, flowId);
return {
content: [{ type: 'text', text: formatFlowResult(result) }]
};
}
case 'qa_list_flows': {
const flows = listFlows(config);
return {
content: [{ type: 'text', text: formatFlowsList(flows) }]
};
}
case 'qa_get_flow_details': {
const { flowId } = args as any;
const flow = getFlowDetails(config, flowId);
if (flow) {
return {
content: [{
type: 'text',
text: `### Fluxo: ${flow.name}\n\n\`\`\`yaml\n${JSON.stringify(flow, null, 2)}\n\`\`\``
}]
};
} else {
return {
content: [{ type: 'text', text: `Fluxo "${flowId}" não encontrado.` }],
isError: true
};
}
}
case 'qa_create_flow': {
const { flowId, ...flowData } = args as any;
const result = createFlow(config, flowData, flowId);
if (result.success) {
return {
content: [{ type: 'text', text: `✅ Fluxo "${flowId}" criado em: ${result.path}` }]
};
} else {
return {
content: [{ type: 'text', text: `❌ Erro: ${result.error}` }],
isError: true
};
}
}
default:
return {
content: [{ type: 'text', text: `Tool desconhecida: ${name}` }],
isError: true
};
}
} catch (error) {
return {
content: [{ type: 'text', text: `Erro ao executar ${name}: ${error}` }],
isError: true
};
}
});
return server;
}
/**
* Inicia o servidor MCP via stdio
*/
export async function startServer(config: QAAgentConfig): Promise<void> {
const server = createMCPServer(config);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP QA Agent iniciado');
}
6.2 Criar src/index.ts
// src/index.ts
import { resolve } from 'path';
import type { QAAgentConfig } from './types/index.js';
import { startServer } from './server.js';
// Carrega configuração do ambiente
const config: QAAgentConfig = {
projectRoot: process.env.PROJECT_ROOT || process.cwd(),
docsPath: process.env.DOCS_PATH || 'docs',
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
screenshotsPath: resolve(process.env.SCREENSHOTS_PATH || './screenshots'),
flowsPath: resolve(process.env.FLOWS_PATH || './flows'),
headless: process.env.HEADLESS === 'true',
defaultTimeout: parseInt(process.env.DEFAULT_TIMEOUT || '30000', 10)
};
// Inicia o servidor
startServer(config).catch((error) => {
console.error('Erro ao iniciar MCP QA Agent:', error);
process.exit(1);
});
Passo 7: Fluxos de Exemplo
7.1 Criar flows/_template.yaml
# Template de Fluxo de Teste
# Copie este arquivo e renomeie para criar novos fluxos
name: "Nome do Fluxo"
description: "Descrição do que este fluxo testa"
module: "NomeDoModulo" # Ex: Licitações, Propostas, Dashboard
requiresAuth: false # true se precisa de login
userRole: "produtor" # Role do usuário para o teste (se requiresAuth: true)
# Credenciais de teste (se requiresAuth: true)
credentials:
email: "teste@example.com"
password: "senha123"
# Pré-condições (documentação)
preconditions:
- "Servidor frontend rodando em localhost:3000"
- "Banco de dados com dados de teste"
# URL inicial (relativa ao baseUrl)
startUrl: "/"
# Passos do teste
steps:
# Navegação
- name: "Acessar página"
action: "navigate"
url: "/pagina"
# Aguardar elemento
- name: "Aguardar carregamento"
action: "wait"
selector: "[data-testid='conteudo']"
# Clicar
- name: "Clicar em botão"
action: "click"
selector: "button[data-testid='acao']"
# Preencher campo
- name: "Preencher input"
action: "fill"
selector: "input[name='campo']"
value: "valor"
# Preencher formulário completo
- name: "Preencher formulário"
action: "fill-form"
fields:
titulo: "Título de Teste"
descricao: "Descrição de teste"
data: "+7days" # Valores especiais: +7days, +30days, today
# Selecionar opção
- name: "Selecionar categoria"
action: "select"
selector: "select[name='categoria']"
value: "opcao1"
# Verificar visibilidade
- name: "Verificar elemento visível"
action: "assert-visible"
selector: ".mensagem-sucesso"
# Verificar texto
- name: "Verificar texto"
action: "assert-text"
selector: ".titulo"
value: "Texto Esperado"
# Verificar URL
- name: "Verificar redirecionamento"
action: "assert-url"
url: "/sucesso"
# Validações finais (documentação)
validations:
- "Nenhum erro no console"
- "Nenhuma requisição com falha"
- "Elemento de sucesso visível"
7.2 Criar flows/login.yaml
name: "Login de Usuário"
description: "Testa o fluxo de login com credenciais válidas"
module: "Autenticação"
requiresAuth: false
startUrl: "/login"
preconditions:
- "Usuário de teste cadastrado no sistema"
steps:
- name: "Verificar página de login carregada"
action: "wait"
selector: "form"
- name: "Verificar campos visíveis"
action: "assert-visible"
selector: "input[type='email'], input[name='email']"
- name: "Preencher email"
action: "fill"
selector: "input[type='email'], input[name='email']"
value: "teste@licitacoes.agro"
- name: "Preencher senha"
action: "fill"
selector: "input[type='password'], input[name='password']"
value: "teste123"
- name: "Clicar em Entrar"
action: "click"
selector: "button[type='submit']"
- name: "Aguardar redirecionamento"
action: "wait"
selector: "[data-testid='dashboard'], .dashboard"
timeout: 10000
- name: "Verificar nome do usuário"
action: "assert-visible"
selector: ".user-name, [data-testid='user-name']"
optional: true
validations:
- "Sem erros de console"
- "Redirecionado para dashboard"
- "Token armazenado (verificar localStorage ou cookie)"
7.3 Criar flows/criar-licitacao.yaml
name: "Criar Nova Licitação"
description: "Testa o fluxo completo de criação de uma licitação"
module: "Licitações"
requiresAuth: true
userRole: "produtor"
credentials:
email: "produtor@licitacoes.agro"
password: "teste123"
startUrl: "/dashboard/produtor"
preconditions:
- "Usuário logado como produtor"
- "Pelo menos 1 categoria cadastrada no sistema"
steps:
- name: "Verificar dashboard carregado"
action: "wait"
selector: "[data-testid='dashboard-produtor'], .dashboard"
- name: "Clicar em Nova Licitação"
action: "click"
selector: "[data-testid='btn-nova-licitacao'], a[href*='nova-licitacao'], button:has-text('Nova Licitação')"
- name: "Aguardar formulário"
action: "wait"
selector: "form[data-testid='form-licitacao'], form"
- name: "Preencher dados da licitação"
action: "fill-form"
fields:
titulo: "Compra de Fertilizantes NPK"
descricao: "Licitação para aquisição de 500kg de fertilizante NPK 20-10-10 para plantio de soja"
quantidade: "500"
unidade: "kg"
- name: "Selecionar categoria"
action: "click"
selector: "[data-testid='select-categoria'], select[name='categoria']"
- name: "Escolher Insumos"
action: "click"
selector: "[data-testid='categoria-insumos'], option[value='insumos']"
optional: true
- name: "Definir data de encerramento"
action: "fill"
selector: "input[name='data_encerramento'], input[type='date']"
value: "+7days"
- name: "Clicar em Publicar"
action: "click"
selector: "[data-testid='btn-publicar'], button[type='submit']:has-text('Publicar')"
- name: "Aguardar confirmação"
action: "wait"
selector: ".toast-success, [data-testid='sucesso'], .alert-success"
timeout: 10000
- name: "Verificar redirecionamento para listagem"
action: "assert-url"
url: "/licitacoes"
optional: true
validations:
- "Licitação criada com sucesso"
- "Redirecionado para listagem ou detalhe"
- "Sem erros de validação no console"
- "Requisição POST /api/licitacoes retornou 201"
Passo 8: Configuração do Claude Code
8.1 Configurar MCP no Claude Desktop/Code
Edite o arquivo de configuração do Claude:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.jsonWindows: %APPDATA%\Claude\claude_desktop_config.jsonLinux: ~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"qa-agent": {
"command": "node",
"args": [
"/caminho/completo/para/seu-monorepo/tools/mcp-qa-agent/dist/index.js"
],
"env": {
"PROJECT_ROOT": "/caminho/completo/para/seu-monorepo",
"DOCS_PATH": "docs",
"BASE_URL": "http://localhost:3000",
"SCREENSHOTS_PATH": "/caminho/completo/para/seu-monorepo/tools/mcp-qa-agent/screenshots",
"FLOWS_PATH": "/caminho/completo/para/seu-monorepo/tools/mcp-qa-agent/flows",
"HEADLESS": "false",
"DEFAULT_TIMEOUT": "30000"
}
}
}
}
8.2 Build do projeto
cd tools/mcp-qa-agent
npm install
npm run build
8.3 Testar localmente
# Testar se o MCP inicia corretamente
npm run dev
Passo 9: Uso no Claude Code
9.1 Comandos disponíveis
Após configurar, o Claude Code terá acesso às seguintes ferramentas:
| Comando | Uso |
|---|---|
qa_load_project_context | "Carregue o contexto do projeto" |
qa_list_flows | "Liste os fluxos de teste disponíveis" |
qa_validate_page | "Valide a página /dashboard" |
qa_run_flow | "Execute o fluxo de login" |
qa_create_flow | "Crie um fluxo para testar o cadastro de fornecedor" |
9.2 Exemplos de interação
Exemplo 1: Validação rápida após mudança
Você: "Terminei de ajustar o componente de listagem de licitações.
Pode validar a página /licitacoes?"
Claude: [Chama qa_validate_page com url="/licitacoes"]
✅ Validação de Página
URL: http://localhost:3000/licitacoes
Título: Licitações | Plataforma Agro
Tempo de carregamento: 1234ms
Nenhum erro de console detectado.
Nenhuma requisição com falha.
📸 Screenshot: screenshots/001-validation-2024-01-15T10-30-00.png
Exemplo 2: Fluxo completo com erros
Você: "Execute o fluxo de criar licitação"
Claude: [Chama qa_run_flow com flowId="criar-licitacao"]
❌ Fluxo "Criar Nova Licitação" FALHOU
Steps: 4 passou | 1 falhou | 2 pulado
📋 Steps Executados
✅ Verificar dashboard carregado (1.2s)
✅ Clicar em Nova Licitação (0.3s)
✅ Aguardar formulário (0.8s)
✅ Preencher dados da licitação (1.1s)
❌ Selecionar categoria (timeout)
⚠️ Elemento não encontrado: [data-testid='select-categoria']
⏭️ Definir data de encerramento
⏭️ Clicar em Publicar
🔴 Erros de Console (1)
- `[Vue warn]: Invalid prop "items" - expected Array, got undefined`
📍 components/CategoriaSelect.vue:42
🔴 Requisições com Falha (1)
- `GET /api/categorias` → 401 Unauthorized
💡 Análise:
O select de categorias não carregou porque a requisição
GET /api/categorias retornou 401. Provavelmente o token
não está sendo enviado no header.
🔧 Sugestão: Verificar se o token está sendo incluído
nas requisições autenticadas. Checar o interceptor do axios.
Exemplo 3: Criar fluxo para nova feature
Você: "Criei uma nova página de perfil do fornecedor em /fornecedor/perfil.
Crie um fluxo de teste para ela."
Claude: Vou criar um fluxo de teste para a página de perfil do fornecedor.
[Chama qa_create_flow com os parâmetros adequados]
✅ Fluxo "perfil-fornecedor" criado em: flows/perfil-fornecedor.yaml
O fluxo inclui:
1. Login como fornecedor
2. Navegação para /fornecedor/perfil
3. Verificação dos campos do perfil
4. Edição de dados
5. Salvamento e confirmação
Quer que eu execute o fluxo agora para validar?
Passo 10: Manutenção e Evolução
10.1 Adicionar novos fluxos
- Copie
flows/_template.yamlparaflows/nome-do-fluxo.yaml - Edite os steps conforme a feature
- O fluxo estará disponível automaticamente
10.2 Atualizar seletores
Quando a UI mudar, atualize os seletores nos arquivos YAML. Prefira sempre data-testid para maior estabilidade:
<!-- No componente Vue/React -->
<button data-testid="btn-publicar">Publicar</button>
# No fluxo
- name: "Clicar em Publicar"
action: "click"
selector: "[data-testid='btn-publicar']"
10.3 Debugging
Para debug visual, mantenha HEADLESS=false no env. O navegador abrirá e você verá a execução em tempo real.
Para mais logs, adicione DEBUG=true ao env e implemente logging adicional no código.
Checklist de Implementação
- Criar estrutura de pastas
tools/mcp-qa-agent/ - Inicializar projeto npm
- Instalar dependências
- Criar
tsconfig.json - Implementar tipos (
src/types/index.ts) - Implementar parser de docs (
src/context/docs-parser.ts) - Implementar browser manager (
src/playwright/browser-manager.ts) - Implementar page analyzer (
src/playwright/page-analyzer.ts) - Implementar tools (
src/tools/*.ts) - Implementar server MCP (
src/server.ts) - Implementar entry point (
src/index.ts) - Criar fluxos de exemplo (
flows/*.yaml) - Build do projeto (
npm run build) - Configurar Claude Desktop (
claude_desktop_config.json) - Testar integração
- Criar fluxos específicos do projeto
Troubleshooting
Erro: "Playwright browsers not installed"
npx playwright install chromium
Erro: "Cannot find module"
npm run build
MCP não aparece no Claude Code
- Verifique se o caminho no
claude_desktop_config.jsonestá correto - Reinicie o Claude Code/Desktop
- Verifique logs do MCP:
npm run dev
Timeout em steps
Aumente o timeout no step específico ou o DEFAULT_TIMEOUT global.
Autenticação falhando
Verifique os seletores do formulário de login no browser-manager.ts e ajuste conforme sua UI.