docs: enterprise refactor plan with ralph specs

This commit is contained in:
debian
2026-03-04 16:17:03 -05:00
parent 4c92712d20
commit f8191133c8
204 changed files with 32722 additions and 422 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
logs
reports
data
.ralph
tests
frontend
.git
*.log

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
ABE_MAX_CONCURRENT_SESSIONS=3
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high
ABE_LOG_LEVEL=info
NODE_ENV=production

48
.github/workflows/abe-example.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: ABE Exploratory Testing
on:
push:
branches: [main]
pull_request:
jobs:
explore:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Start application
run: docker-compose up -d app
# assumes the project has a docker-compose with the target app
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run ABE
run: |
npm run abe -- run \
--url http://localhost:3000 \
--max-states 30 \
--fail-on-severity high \
--output junit
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: reports/
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: abe-results.xml

3
.gitignore vendored
View File

@@ -16,6 +16,9 @@
.ralph/docs/generated/*
!.ralph/docs/generated/.gitkeep
# Environment
.env
# General logs
*.log

1
.ralph/.loop_start_sha Normal file
View File

@@ -0,0 +1 @@
4c92712d204993bdd6ff2a7b60583e28ac4f87b1

View File

@@ -1,158 +1,69 @@
# Agent Build Instructions
# ABE — Build, Test & Development Commands
## Project Setup
## Install dependencies
```bash
# Install dependencies (example for Node.js project)
npm install
# Or for Python project
pip install -r requirements.txt
# Or for Rust project
cargo build
cd frontend && npm install && cd ..
```
## Running Tests
## Build (backend)
```bash
# Node.js
npm test
# Python
pytest
# Rust
cargo test
```
## Build Commands
```bash
# Production build
npm run build
# or
cargo build --release
```
## Development Server
## Build (frontend)
```bash
# Start development server
npm run dev
# or
cargo run
cd frontend && npm run build
```
## Key Learnings
- Update this section when you learn new build optimizations
- Document any gotchas or special setup requirements
- Keep track of the fastest test/build cycle
## Test
```bash
npm run test
```
## Feature Development Quality Standards
## Lint
```bash
npm run lint
```
**CRITICAL**: All new features MUST meet the following mandatory requirements before being considered complete.
## Type check
```bash
npm run typecheck
```
### Testing Requirements
## Dev mode
```bash
npm run dev # backend con hot reload
cd frontend && npm run dev # frontend dev server
```
- **Minimum Coverage**: 85% code coverage ratio required for all new code
- **Test Pass Rate**: 100% - all tests must pass, no exceptions
- **Test Types Required**:
- Unit tests for all business logic and services
- Integration tests for API endpoints or main functionality
- End-to-end tests for critical user workflows
- **Coverage Validation**: Run coverage reports before marking features complete:
```bash
# Examples by language/framework
npm run test:coverage
pytest --cov=src tests/ --cov-report=term-missing
cargo tarpaulin --out Html
```
- **Test Quality**: Tests must validate behavior, not just achieve coverage metrics
- **Test Documentation**: Complex test scenarios must include comments explaining the test strategy
## Database
```bash
npm run db:migrate # ejecutar migraciones Kysely
```
### Git Workflow Requirements
## Docker
```bash
docker compose up -d --build
docker compose logs -f
docker compose down
```
Before moving to the next feature, ALL changes must be:
## Verificación completa (ejecutar después de CADA tarea)
```bash
npm run build && cd frontend && npm run build && cd .. && npm run test
```
1. **Committed with Clear Messages**:
```bash
git add .
git commit -m "feat(module): descriptive message following conventional commits"
```
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, etc.
- Include scope when applicable: `feat(api):`, `fix(ui):`, `test(auth):`
- Write descriptive messages that explain WHAT changed and WHY
## Commit después de tarea completada
```bash
git add -A && git commit -m "fase(X.Y): descripción"
```
2. **Pushed to Remote Repository**:
```bash
git push origin <branch-name>
```
- Never leave completed features uncommitted
- Push regularly to maintain backup and enable collaboration
- Ensure CI/CD pipelines pass before considering feature complete
## Notas
- Source code: src/
- Frontend: frontend/
- Tests: junto al código (*.test.ts) o en tests/
- Reports output: reports/
- Logs: logs/
- Database: data/abe.db
3. **Branch Hygiene**:
- Work on feature branches, never directly on `main`
- Branch naming convention: `feature/<feature-name>`, `fix/<issue-name>`, `docs/<doc-update>`
- Create pull requests for all significant changes
4. **Ralph Integration**:
- Update .ralph/fix_plan.md with new tasks before starting work
- Mark items complete in .ralph/fix_plan.md upon completion
- Update .ralph/PROMPT.md if development patterns change
- Test features work within Ralph's autonomous loop
### Documentation Requirements
**ALL implementation documentation MUST remain synchronized with the codebase**:
1. **Code Documentation**:
- Language-appropriate documentation (JSDoc, docstrings, etc.)
- Update inline comments when implementation changes
- Remove outdated comments immediately
2. **Implementation Documentation**:
- Update relevant sections in this AGENT.md file
- Keep build and test commands current
- Update configuration examples when defaults change
- Document breaking changes prominently
3. **README Updates**:
- Keep feature lists current
- Update setup instructions when dependencies change
- Maintain accurate command examples
- Update version compatibility information
4. **AGENT.md Maintenance**:
- Add new build patterns to relevant sections
- Update "Key Learnings" with new insights
- Keep command examples accurate and tested
- Document new testing patterns or quality gates
### Feature Completion Checklist
Before marking ANY feature as complete, verify:
- [ ] All tests pass with appropriate framework command
- [ ] Code coverage meets 85% minimum threshold
- [ ] Coverage report reviewed for meaningful test quality
- [ ] Code formatted according to project standards
- [ ] Type checking passes (if applicable)
- [ ] All changes committed with conventional commit messages
- [ ] All commits pushed to remote repository
- [ ] .ralph/fix_plan.md task marked as complete
- [ ] Implementation documentation updated
- [ ] Inline code comments updated or added
- [ ] .ralph/AGENT.md updated (if new patterns introduced)
- [ ] Breaking changes documented
- [ ] Features tested within Ralph loop (if applicable)
- [ ] CI/CD pipeline passes
### Rationale
These standards ensure:
- **Quality**: High test coverage and pass rates prevent regressions
- **Traceability**: Git commits and .ralph/fix_plan.md provide clear history of changes
- **Maintainability**: Current documentation reduces onboarding time and prevents knowledge loss
- **Collaboration**: Pushed changes enable team visibility and code review
- **Reliability**: Consistent quality gates maintain production stability
- **Automation**: Ralph integration ensures continuous development practices
**Enforcement**: AI agents should automatically apply these standards to all feature development tasks without requiring explicit instruction for each task.

View File

@@ -1,296 +1,182 @@
# Ralph Development Instructions
## Context
You are Ralph, an autonomous AI development agent working on a [YOUR PROJECT NAME] project.
## Current Objectives
1. Study .ralph/specs/* to learn about the project specifications
2. Review .ralph/fix_plan.md for current priorities
3. Implement the highest priority item using best practices
4. Use parallel subagents for complex tasks (max 100 concurrent)
5. Run tests after each implementation
6. Update documentation and fix_plan.md
## Key Principles
- ONE task per loop - focus on the most important thing
- Search the codebase before assuming something isn't implemented
- Use subagents for expensive operations (file searching, analysis)
- Write comprehensive tests with clear documentation
- Update .ralph/fix_plan.md with your learnings
- Commit working changes with descriptive messages
## Protected Files (DO NOT MODIFY)
The following files and directories are part of Ralph's infrastructure.
NEVER delete, move, rename, or overwrite these under any circumstances:
- .ralph/ (entire directory and all contents)
- .ralphrc (project configuration)
When performing cleanup, refactoring, or restructuring tasks:
- These files are NOT part of your project code
- They are Ralph's internal control files that keep the development loop running
- Deleting them will break Ralph and halt all autonomous development
## 🧪 Testing Guidelines (CRITICAL)
- LIMIT testing to ~20% of your total effort per loop
- PRIORITIZE: Implementation > Documentation > Tests
- Only write tests for NEW functionality you implement
- Do NOT refactor existing tests unless broken
- Do NOT add "additional test coverage" as busy work
- Focus on CORE functionality first, comprehensive testing later
## Execution Guidelines
- Before making changes: search codebase using subagents
- After implementation: run ESSENTIAL tests for the modified code only
- If tests fail: fix them as part of your current work
- Keep .ralph/AGENT.md updated with build/run instructions
- Document the WHY behind tests and implementations
- No placeholder implementations - build it properly
## 🎯 Status Reporting (CRITICAL - Ralph needs this!)
**IMPORTANT**: At the end of your response, ALWAYS include this status block:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS | COMPLETE | BLOCKED
TASKS_COMPLETED_THIS_LOOP: <number>
FILES_MODIFIED: <number>
TESTS_STATUS: PASSING | FAILING | NOT_RUN
WORK_TYPE: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
EXIT_SIGNAL: false | true
RECOMMENDATION: <one line summary of what to do next>
---END_RALPH_STATUS---
```
### When to set EXIT_SIGNAL: true
Set EXIT_SIGNAL to **true** when ALL of these conditions are met:
1. ✅ All items in fix_plan.md are marked [x]
2. ✅ All tests are passing (or no tests exist for valid reasons)
3. ✅ No errors or warnings in the last execution
4. ✅ All requirements from specs/ are implemented
5. ✅ You have nothing meaningful left to implement
### Examples of proper status reporting:
**Example 1: Work in progress**
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 2
FILES_MODIFIED: 5
TESTS_STATUS: PASSING
WORK_TYPE: IMPLEMENTATION
EXIT_SIGNAL: false
RECOMMENDATION: Continue with next priority task from fix_plan.md
---END_RALPH_STATUS---
```
**Example 2: Project complete**
```
---RALPH_STATUS---
STATUS: COMPLETE
TASKS_COMPLETED_THIS_LOOP: 1
FILES_MODIFIED: 1
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: All requirements met, project ready for review
---END_RALPH_STATUS---
```
**Example 3: Stuck/blocked**
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: FAILING
WORK_TYPE: DEBUGGING
EXIT_SIGNAL: false
RECOMMENDATION: Need human help - same error for 3 loops
---END_RALPH_STATUS---
```
### What NOT to do:
- ❌ Do NOT continue with busy work when EXIT_SIGNAL should be true
- ❌ Do NOT run tests repeatedly without implementing new features
- ❌ Do NOT refactor code that is already working fine
- ❌ Do NOT add features not in the specifications
- ❌ Do NOT forget to include the status block (Ralph depends on it!)
## 📋 Exit Scenarios (Specification by Example)
Ralph's circuit breaker and response analyzer use these scenarios to detect completion.
Each scenario shows the exact conditions and expected behavior.
### Scenario 1: Successful Project Completion
**Given**:
- All items in .ralph/fix_plan.md are marked [x]
- Last test run shows all tests passing
- No errors in recent logs/
- All requirements from .ralph/specs/ are implemented
**When**: You evaluate project status at end of loop
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: COMPLETE
TASKS_COMPLETED_THIS_LOOP: 1
FILES_MODIFIED: 1
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: All requirements met, project ready for review
---END_RALPH_STATUS---
```
**Ralph's Action**: Detects EXIT_SIGNAL=true, gracefully exits loop with success message
# ABE — Autonomous Bug Explorer
## Instrucciones Maestras para Claude Code (via Ralph)
---
### Scenario 2: Test-Only Loop Detected
**Given**:
- Last 3 loops only executed tests (npm test, bats, pytest, etc.)
- No new files were created
- No existing files were modified
- No implementation work was performed
## Visión del proyecto
**When**: You start a new loop iteration
ABE es una plataforma enterprise self-hosted de descubrimiento autónomo de bugs
en aplicaciones web. Explora apps como un usuario real, inyecta inputs inválidos
(fuzzing), detecta anomalías, y genera bug reports reproducibles.
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: PASSING
WORK_TYPE: TESTING
EXIT_SIGNAL: false
RECOMMENDATION: All tests passing, no implementation needed
---END_RALPH_STATUS---
```
**Posicionamiento**: "Playwright discovers what you test. ABE discovers what you miss."
**Ralph's Action**: Increments test_only_loops counter, exits after 3 consecutive test-only loops
Modelo open-core enterprise self-hosted:
- Free/OSS: exploración autónoma + reports básicos
- Pro: dashboards avanzados, integraciones, CLI/CI
- Enterprise: SSO, RBAC avanzado, LDAP, audit logs, licencia
---
### Scenario 3: Stuck on Recurring Error
**Given**:
- Same error appears in last 5 consecutive loops
- No progress on fixing the error
- Error message is identical or very similar
## Estado actual del código
**When**: You encounter the same error again
**Then**: You must output:
Las fases 1-11 del proyecto original están implementadas con esta estructura:
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 2
TESTS_STATUS: FAILING
WORK_TYPE: DEBUGGING
EXIT_SIGNAL: false
RECOMMENDATION: Stuck on [error description] - human intervention needed
---END_RALPH_STATUS---
src/
├── core/ ← interfaces.ts, ExplorationEngine, StateGraph, AnomalyDetector
├── plugins/ ← PlaywrightAgent, collectors, exporters, fuzzers, reproducers
├── server/ ← Express API server + socket.io
├── db/ ← SQLite repositories (better-sqlite3)
├── cli.ts ← CLI entry point
frontend/ ← React + Vite + Tailwind (básico)
```
**Ralph's Action**: Circuit breaker detects repeated errors, opens circuit after 5 loops
El objetivo es REFACTORIZAR este código existente hacia una arquitectura
modular hexagonal, migrando pieza por pieza sin romper funcionalidad.
**ESTRATEGIA DE MIGRACIÓN**: No reescribir de cero. Mover código existente
a la nueva estructura, adaptar interfaces, y verificar que todo sigue funcionando
después de cada movimiento.
---
### Scenario 4: No Work Remaining
**Given**:
- All tasks in fix_plan.md are complete
- You analyze .ralph/specs/ and find nothing new to implement
- Code quality is acceptable
- Tests are passing
## Arquitectura objetivo: Modular Monolith Hexagonal
**When**: You search for work to do and find none
**Then**: You must output:
### Principio fundamental
```
---RALPH_STATUS---
STATUS: COMPLETE
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: PASSING
WORK_TYPE: DOCUMENTATION
EXIT_SIGNAL: true
RECOMMENDATION: No remaining work, all .ralph/specs implemented
---END_RALPH_STATUS---
Infrastructure → Application → Domain
(el código SIEMPRE apunta hacia adentro, nunca al revés)
```
**Ralph's Action**: Detects completion signal, exits loop immediately
### Estructura de carpetas OBJETIVO
```
src/
├── shared/
│ ├── domain/
│ │ ├── Entity.ts
│ │ ├── AggregateRoot.ts
│ │ ├── ValueObject.ts
│ │ ├── UniqueId.ts
│ │ ├── Result.ts
│ │ └── DomainEvent.ts
│ ├── application/
│ │ ├── UseCase.ts
│ │ ├── EventBus.ts
│ │ └── EventHandler.ts
│ └── infrastructure/
│ ├── InProcessEventBus.ts
│ ├── DatabaseConnection.ts
│ ├── Logger.ts
│ ├── Config.ts
│ └── StorageProvider.ts
├── modules/
│ ├── crawling/
│ │ ├── domain/ (entities, value-objects, events, ports)
│ │ ├── application/ (commands, queries, event-handlers)
│ │ └── infrastructure/(adapters, repositories, http)
│ ├── fuzzing/ (misma estructura)
│ ├── findings/ (misma estructura)
│ ├── auth/ (misma estructura)
│ ├── reporting/ (misma estructura)
│ ├── integrations/ (misma estructura)
│ ├── scheduling/ (misma estructura)
│ └── licensing/ (misma estructura)
├── api/
│ ├── server.ts
│ ├── router.ts
│ └── middleware/
├── realtime/
│ └── SocketGateway.ts
├── jobs/
│ ├── JobQueue.ts
│ ├── SQLiteJobQueue.ts
│ └── workers/
├── cli/
│ └── abe.ts
└── main.ts ← composition root
```
---
### Scenario 5: Making Progress
**Given**:
- Tasks remain in .ralph/fix_plan.md
- Implementation is underway
- Files are being modified
- Tests are passing or being fixed
## Reglas de arquitectura INQUEBRANTABLES
**When**: You complete a task successfully
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: IN_PROGRESS
TASKS_COMPLETED_THIS_LOOP: 3
FILES_MODIFIED: 7
TESTS_STATUS: PASSING
WORK_TYPE: IMPLEMENTATION
EXIT_SIGNAL: false
RECOMMENDATION: Continue with next task from .ralph/fix_plan.md
---END_RALPH_STATUS---
```
**Ralph's Action**: Continues loop, circuit breaker stays CLOSED (normal operation)
1. **Domain layer NO importa nada externo** — ni Kysely, ni Express, ni Playwright.
2. **Cross-module communication SOLO via EventBus** — NUNCA import directo entre módulos.
3. **Cada módulo exporta SOLO su facade** via `index.ts`.
4. **Controllers son THIN** — parsean request, llaman use case, formatean response.
5. **Use Cases retornan Result<T, E>** — NUNCA throw para errores de negocio.
6. **Un archivo = una clase = una responsabilidad**.
7. **Determinista** — no usar Math.random() sin seed. Loguear siempre el seed.
8. **Serializable** — entities y value objects JSON.stringify-able.
9. **No AI en el core loop** — AIEnrichment es post-proceso opcional.
10. **Plugins nunca se importan desde core** — core solo define interfaces/ports.
---
### Scenario 6: Blocked on External Dependency
**Given**:
- Task requires external API, library, or human decision
- Cannot proceed without missing information
- Have tried reasonable workarounds
## Stack tecnológico
**When**: You identify the blocker
### Backend
- Runtime: Node.js 20 + TypeScript 5.x (strict mode)
- HTTP: Express.js 4.x
- WebSocket: socket.io 4.x
- Database: Kysely (query builder) + better-sqlite3 (default) | pg (enterprise)
- Validation: Zod (schemas compartidos frontend/backend)
- Auth: Better Auth + CASL
- Browser: Playwright
- Logger: Pino + pino-pretty (dev)
- Jobs: SQLite-backed queue custom con worker_threads
- Scheduler: node-cron
- Security: Helmet, express-rate-limit, cors
- API docs: zod-to-openapi + Scalar UI
- Testing: Vitest + supertest (integration)
**Then**: You must output:
```
---RALPH_STATUS---
STATUS: BLOCKED
TASKS_COMPLETED_THIS_LOOP: 0
FILES_MODIFIED: 0
TESTS_STATUS: NOT_RUN
WORK_TYPE: IMPLEMENTATION
EXIT_SIGNAL: false
RECOMMENDATION: Blocked on [specific dependency] - need [what's needed]
---END_RALPH_STATUS---
```
**Ralph's Action**: Logs blocker, may exit after multiple blocked loops
### Frontend
- React 18 + Vite + TypeScript
- shadcn/ui (Radix UI + Tailwind CSS)
- Tremor + Recharts (charts/dashboards)
- TanStack Table + TanStack Query
- Zustand (client state)
- React Hook Form + Zod resolver
- socket.io-client
- Framer Motion
---
## File Structure
- .ralph/: Ralph-specific configuration and documentation
- specs/: Project specifications and requirements
- fix_plan.md: Prioritized TODO list
- AGENT.md: Project build and run instructions
- PROMPT.md: This file - Ralph development instructions
- logs/: Loop execution logs
- docs/generated/: Auto-generated documentation
- src/: Source code implementation
- examples/: Example usage and test cases
## REGLAS OBLIGATORIAS PARA CADA TAREA
## Current Task
Follow .ralph/fix_plan.md and choose the most important item to implement next.
Use your judgment to prioritize what will have the biggest impact on project progress.
### Antes de empezar
1. Leer la tarea actual del fix_plan.md
2. Leer la spec correspondiente en .ralph/specs/ SI existe
3. Verificar que las dependencias (tareas previas) están completas
Remember: Quality over speed. Build it right the first time. Know when you're done.
### Después de CADA tarea individual
1. `npm run build` — DEBE compilar sin errores
2. `cd frontend && npm run build` — DEBE compilar sin errores
3. `npm run test` — DEBE pasar (o no romper tests existentes)
4. Si ALGUNO falla → NO marcar tarea → arreglar PRIMERO
### Después de marcar tarea como completa
1. `git add -A`
2. `git commit -m "fase(X.Y): descripción breve de la tarea"`
Ejemplo: `git commit -m "fase(1.3): create ValueObject base class with equals"`
3. Verificar que el commit se hizo correctamente
### Reglas de código
- Todo nuevo código DEBE tener tipos explícitos (CERO `any`)
- Imports ordenados: node_modules → shared → modules → relative
- Nombres: PascalCase para clases, camelCase para funciones/variables
- Cada módulo nuevo DEBE tener al menos un test unitario
- Código existente que se MUEVE debe seguir funcionando igual
---
## Señal de completado
Cuando TODAS las tareas en fix_plan.md estén marcadas [x]:
RALPH_STATUS:
completion_indicators: done
EXIT_SIGNAL: true
summary: "ABE enterprise refactor complete."

View File

@@ -1,27 +1,444 @@
# Ralph Fix Plan
# ABE Enterprise Refactor — Fix Plan
## High Priority
- [ ] Set up basic project structure and build system
- [ ] Define core data structures and types
- [ ] Implement basic input/output handling
- [ ] Create test framework and initial tests
## REGLAS CRÍTICAS
1. NO pasar a la siguiente tarea si el build falla
2. Hacer `git commit` después de CADA tarea completada
3. Leer la spec en `.ralph/specs/` ANTES de cada phase
4. Los tests DEBEN pasar antes de marcar [x]
5. Formato commit: `git commit -m "fase(X.Y): descripción"`
## Medium Priority
- [ ] Add error handling and validation
- [ ] Implement core business logic
- [ ] Add configuration management
- [ ] Create user documentation
---
## Low Priority
- [ ] Performance optimization
- [ ] Extended feature set
- [ ] Integration with external services
- [ ] Advanced error recovery
## Phase 0: Hotfix — Build actual funcional [PENDIENTE]
## Completed
- [x] Project initialization
- [ ] 0.1: Fix errores TypeScript en src/ que impidan compilación (IAnomaly import, NodeListOf iterator, cualquier otro)
- [ ] 0.2: Verificar `npm run build` pasa con 0 errores
- [ ] 0.3: Verificar `cd frontend && npm run build` pasa con 0 errores
- [ ] 0.4: Verificar que la app arranca con `npm run dev` sin crash
- [ ] 0.5: Commit: `git add -A && git commit -m "fase(0): fix build errors"`
## Notes
- Focus on MVP functionality first
- Ensure each feature is properly tested
- Update this file after each major milestone
---
## Phase 1: Shared Domain — Building Blocks [PENDIENTE]
Spec: `.ralph/specs/phase-01-shared-domain.md`
- [ ] 1.1: Crear directorio `src/shared/domain/`
- [ ] 1.2: Crear `src/shared/domain/Result.ts` — Result<T, E> con Ok(), Err(), isOk(), isErr()
- [ ] 1.3: Crear `src/shared/domain/UniqueId.ts` — UUID v4 wrapper con create(), toString(), equals()
- [ ] 1.4: Crear `src/shared/domain/Entity.ts` — base class con _id: UniqueId, equals()
- [ ] 1.5: Crear `src/shared/domain/AggregateRoot.ts` — extends Entity + domainEvents[], addDomainEvent(), clearEvents()
- [ ] 1.6: Crear `src/shared/domain/ValueObject.ts` — base class inmutable con props frozen, equals()
- [ ] 1.7: Crear `src/shared/domain/DomainEvent.ts` — interface: eventId, eventName, aggregateId, occurredOn, payload
- [ ] 1.8: Crear `src/shared/application/UseCase.ts` — interface: execute(req) → Promise<Result<TRes, TErr>>
- [ ] 1.9: Crear `src/shared/application/EventBus.ts` — interface: publish(event), subscribe(name, handler)
- [ ] 1.10: Crear `src/shared/application/EventHandler.ts` — interface: handle(event) → Promise<void>
- [ ] 1.11: Crear `src/shared/domain/index.ts` — barrel export de todo shared/domain
- [ ] 1.12: Crear `src/shared/application/index.ts` — barrel export de todo shared/application
- [ ] 1.13: Tests unitarios: Result (Ok/Err/isOk/isErr), Entity (equals by id), ValueObject (equals by props), UniqueId (create/equals)
- [ ] 1.14: Verificar build completo + commit: `fase(1): shared domain building blocks`
---
## Phase 2: Shared Infrastructure [PENDIENTE]
Spec: `.ralph/specs/phase-02-shared-infrastructure.md`
- [ ] 2.1: Instalar deps: `npm i kysely better-sqlite3 pino pino-pretty zod helmet express-rate-limit dotenv uuid` + `npm i -D @types/better-sqlite3 @types/uuid`
- [ ] 2.2: Crear `src/shared/infrastructure/Config.ts` — Zod schema para TODAS las env vars con defaults sensatos
- [ ] 2.3: Crear `src/shared/infrastructure/Logger.ts` — Pino factory: createLogger(config) retorna pino.Logger, pino-pretty en dev
- [ ] 2.4: Crear `src/shared/infrastructure/DatabaseConnection.ts` — Kysely factory: createDatabase(config) soporta SQLite (default) y PostgreSQL (si config.db.driver === 'postgres')
- [ ] 2.5: Crear `src/shared/infrastructure/InProcessEventBus.ts` — implementa EventBus con Node EventEmitter, logging de eventos, error handling en handlers
- [ ] 2.6: Crear `src/shared/infrastructure/StorageProvider.ts` — interface IStorageProvider (save/get/delete/exists) + LocalStorageProvider (filesystem)
- [ ] 2.7: Crear `src/shared/infrastructure/index.ts` — barrel export
- [ ] 2.8: Crear `src/db/migrations/001_initial_schema.ts` — migración Kysely que crea las tablas existentes (sessions, states, actions, anomalies, notifications) con IF NOT EXISTS
- [ ] 2.9: Crear `src/db/migrator.ts` — setup Kysely Migrator + función runMigrations()
- [ ] 2.10: Añadir script `"db:migrate"` a package.json
- [ ] 2.11: Tests: Config validation (valid + invalid), EventBus (publish/subscribe/error handling), StorageProvider (save/get/delete)
- [ ] 2.12: Verificar build completo + commit: `fase(2): shared infrastructure layer`
---
## Phase 3: Crawling Module — Domain + Application [PENDIENTE]
Spec: `.ralph/specs/phase-03-crawling-domain.md`
- [ ] 3.1: Crear `src/modules/crawling/domain/entities/CrawlSession.ts` — AggregateRoot con url, status, seed, maxStates, statesVisited, config
- [ ] 3.2: Crear `src/modules/crawling/domain/entities/CrawlState.ts` — Entity con url, title, domSnapshot, visitCount
- [ ] 3.3: Crear `src/modules/crawling/domain/entities/CrawlAction.ts` — Entity con type, selector, value, seed, stateId, sequenceOrder
- [ ] 3.4: Crear value objects: `Url.ts`, `Selector.ts`, `SessionStatus.ts` (running/completed/failed/stopped)
- [ ] 3.5: Crear events: `CrawlStarted.ts`, `StateDiscovered.ts`, `ActionExecuted.ts`, `CrawlCompleted.ts`, `CrawlFailed.ts`
- [ ] 3.6: Crear ports: `ICrawlerEngine.ts` (launch/close/discoverActions/executeAction/captureState), `ICrawlSessionRepository.ts` (save/findById/findAll/update), `IStateRepository.ts`
- [ ] 3.7: Crear `application/commands/StartCrawlCommand.ts` — use case que valida config, crea CrawlSession, emite CrawlStarted
- [ ] 3.8: Crear `application/commands/StopCrawlCommand.ts` — use case que para sesión, emite CrawlCompleted
- [ ] 3.9: Crear `application/queries/GetSessionQuery.ts` y `ListSessionsQuery.ts`
- [ ] 3.10: Crear `modules/crawling/index.ts` — barrel export público
- [ ] 3.11: Tests: CrawlSession creation + domain events, StartCrawlCommand con mock repository
- [ ] 3.12: Verificar build + commit: `fase(3): crawling module domain and application`
---
## Phase 4: Crawling Module — Infrastructure (migración código existente) [PENDIENTE]
Spec: `.ralph/specs/phase-04-crawling-infrastructure.md`
- [ ] 4.1: Copiar `src/plugins/agents/PlaywrightAgent.ts``src/modules/crawling/infrastructure/adapters/PlaywrightCrawlerEngine.ts`, adaptar para implementar ICrawlerEngine port
- [ ] 4.2: Copiar `src/core/StateGraph.ts``src/modules/crawling/infrastructure/adapters/StateGraph.ts`, mantener lógica BFS
- [ ] 4.3: Copiar `src/core/ExplorationEngine.ts``src/modules/crawling/infrastructure/adapters/ExplorationOrchestrator.ts`, adaptar para usar ports en vez de imports directos
- [ ] 4.4: Crear `infrastructure/repositories/KyselyCrawlSessionRepository.ts` — implementa ICrawlSessionRepository con Kysely
- [ ] 4.5: Crear `infrastructure/repositories/KyselyStateRepository.ts`
- [ ] 4.6: Crear `infrastructure/http/CrawlingController.ts` — Express routes: POST /api/sessions, GET /api/sessions, GET /api/sessions/:id, DELETE /api/sessions/:id
- [ ] 4.7: Verificar que crear sesión + ejecutar crawl funciona end-to-end
- [ ] 4.8: Verificar build + commit: `fase(4): crawling infrastructure with migrated code`
---
## Phase 5: Findings Module [PENDIENTE]
Spec: `.ralph/specs/phase-05-findings-module.md`
- [ ] 5.1: Crear `domain/entities/Finding.ts` — AggregateRoot con severity, type, evidence, status, actionTrace
- [ ] 5.2: Crear value objects: `Severity.ts` (low/medium/high/critical), `FindingType.ts`, `Evidence.ts`, `FindingStatus.ts` (open/investigating/resolved/closed)
- [ ] 5.3: Crear events: `FindingCreated.ts`, `FindingResolved.ts`, `FindingEnriched.ts`
- [ ] 5.4: Crear ports: `IFindingRepository.ts`, `IAIEnricher.ts`
- [ ] 5.5: Crear commands: `CreateFindingCommand.ts`, `EnrichFindingCommand.ts`, `ResolveFindingCommand.ts`
- [ ] 5.6: Crear queries: `GetFindingQuery.ts`, `ListFindingsQuery.ts` (filtros: severity, type, session, status, search), `FindingStatsQuery.ts`
- [ ] 5.7: Crear `event-handlers/OnAnomalyDetected.ts` — escucha eventos crawling → crea Finding
- [ ] 5.8: Crear `infrastructure/repositories/KyselyFindingRepository.ts`
- [ ] 5.9: Migrar exporters existentes → `infrastructure/exporters/` (MarkdownExporter, JSONExporter)
- [ ] 5.10: Crear `infrastructure/exporters/PlaywrightScriptExporter.ts` — genera test Playwright reproducible desde actionTrace
- [ ] 5.11: Crear `infrastructure/http/FindingsController.ts` — routes para anomalies existentes + nuevas
- [ ] 5.12: Migración Kysely: tabla findings con columnas status, browser, ai_enrichment_json
- [ ] 5.13: Tests: Finding aggregate, CreateFinding, ListFindings con filtros
- [ ] 5.14: Verificar build + commit: `fase(5): findings module complete`
---
## Phase 6: Fuzzing Module [PENDIENTE]
Spec: `.ralph/specs/phase-06-fuzzing-module.md`
- [ ] 6.1: Crear domain: `FuzzSession.ts` (AggregateRoot), `FuzzResult.ts` (Entity)
- [ ] 6.2: Crear value objects: `FuzzStrategy.ts`, `FuzzPayload.ts`, `Seed.ts`, `FuzzIntensity.ts`
- [ ] 6.3: Crear events: `FuzzStarted.ts`, `VulnerabilityDetected.ts`, `FuzzCompleted.ts`
- [ ] 6.4: Crear port: `IFuzzerEngine.ts`
- [ ] 6.5: Crear `commands/RunFuzzCommand.ts`
- [ ] 6.6: Crear `event-handlers/OnActionExecuted.ts` — escucha crawling → trigger fuzzing
- [ ] 6.7: Migrar las 5 estrategias existentes → `infrastructure/strategies/` (Empty, Oversized, SpecialChars, TypeMismatch, Boundary)
- [ ] 6.8: Migrar `FuzzingEngine.ts` y `InputTypeDetector.ts``infrastructure/adapters/`
- [ ] 6.9: Crear `infrastructure/http/FuzzingController.ts`
- [ ] 6.10: Tests: cada estrategia de fuzzing genera payloads válidos
- [ ] 6.11: Verificar build + commit: `fase(6): fuzzing module complete`
---
## Phase 7: API Server Refactor + Composition Root [PENDIENTE]
Spec: `.ralph/specs/phase-07-api-server.md`
- [ ] 7.1: Crear `src/api/middleware/errorHandler.ts` — AppError hierarchy (ValidationError, AuthenticationError, ForbiddenError, NotFoundError) + global error handler
- [ ] 7.2: Crear `src/api/middleware/requestId.ts` — genera UUID por request, adjunta a req + pino child logger
- [ ] 7.3: Crear `src/api/middleware/notFound.ts` — 404 handler para rutas no encontradas
- [ ] 7.4: Crear `src/api/server.ts` — Express app con middleware stack: requestId → helmet → cors → rateLimit → bodyParser → routes → notFound → errorHandler
- [ ] 7.5: Crear `src/api/router.ts` — registra routes de TODOS los módulos (crawling, findings, fuzzing)
- [ ] 7.6: Crear `src/realtime/SocketGateway.ts` — socket.io server que subscribe a EventBus y emite a clientes
- [ ] 7.7: Crear `src/main.ts` — composition root: load config → create logger → create db → run migrations → create event bus → create repositories → create use cases → subscribe handlers → create controllers → create Express → create socket.io → start listening
- [ ] 7.8: Implementar graceful shutdown en main.ts: SIGTERM/SIGINT → stop accepting → close sockets → close db → flush logs → exit
- [ ] 7.9: Health endpoints: GET /health/live (process alive), GET /health/ready (DB check)
- [ ] 7.10: Verificar que TODOS los endpoints existentes siguen funcionando tras refactor
- [ ] 7.11: Verificar build + commit: `fase(7): api server refactor with composition root`
---
## Phase 8: Job Queue System [PENDIENTE]
Spec: `.ralph/specs/phase-08-job-queue.md`
- [ ] 8.1: Crear `src/jobs/JobQueue.ts` — interface: enqueue, start, pause, waitForActive
- [ ] 8.2: Crear `src/jobs/SQLiteJobQueue.ts` — tabla jobs con status/type/payload/attempts/run_at, polling worker
- [ ] 8.3: Migración Kysely: tabla jobs
- [ ] 8.4: Crear `src/jobs/workers/ExplorationWorker.ts` — ejecuta crawl como job
- [ ] 8.5: Crear `src/jobs/workers/ReportWorker.ts` — genera reports en background
- [ ] 8.6: Integrar job queue en main.ts, mover exploraciones de sync a job-based
- [ ] 8.7: Tests: enqueue → dequeue → complete cycle, failed job retry
- [ ] 8.8: Verificar build + commit: `fase(8): sqlite job queue system`
---
## Phase 9: Auth Module [PENDIENTE]
Spec: `.ralph/specs/phase-09-auth-module.md`
- [ ] 9.1: Instalar: `npm i better-auth @casl/ability argon2`
- [ ] 9.2: Crear domain: `User.ts` (AggregateRoot), `Organization.ts` (AggregateRoot), `Team.ts` (Entity), `ApiKey.ts` (Entity)
- [ ] 9.3: Crear value objects: `Email.ts`, `Role.ts` (owner/admin/member/viewer), `Permission.ts`
- [ ] 9.4: Crear events: `UserCreated.ts`, `UserLoggedIn.ts`, `OrgCreated.ts`, `MemberInvited.ts`
- [ ] 9.5: Crear ports: `IUserRepository.ts`, `IOrganizationRepository.ts`
- [ ] 9.6: Crear commands: `RegisterCommand.ts`, `LoginCommand.ts`, `CreateOrganizationCommand.ts`, `InviteMemberCommand.ts`, `CreateApiKeyCommand.ts`
- [ ] 9.7: Crear queries: `GetUserQuery.ts`, `ListOrgMembersQuery.ts`
- [ ] 9.8: Crear `infrastructure/better-auth/authConfig.ts` — setup Better Auth con SQLite adapter, email+password, organization plugin con roles
- [ ] 9.9: Crear `infrastructure/casl/AbilityFactory.ts` — define permisos por role (owner: manage all, admin: manage all except delete org, member: create/read sessions+findings, viewer: read all)
- [ ] 9.10: Crear `application/middleware/AuthMiddleware.ts` — intenta session cookie → JWT → API key → 401
- [ ] 9.11: Crear `application/middleware/RBACMiddleware.ts` — verifica permisos CASL por ruta
- [ ] 9.12: Crear `infrastructure/repositories/KyselyUserRepository.ts`
- [ ] 9.13: Crear `infrastructure/http/AuthController.ts` — POST /api/auth/register, POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me, GET /api/auth/setup-required
- [ ] 9.14: Migración Kysely: tablas users, organizations, teams, org_members, api_keys, auth_sessions
- [ ] 9.15: First-run detection: si 0 users → GET /api/auth/setup-required retorna { required: true }
- [ ] 9.16: POST /api/auth/setup — crea primer user como owner + organización default
- [ ] 9.17: Integrar AuthMiddleware en todas las rutas /api/ excepto /health/* y /api/auth/*
- [ ] 9.18: Tests: register, login, RBAC permissions (admin can create session, viewer cannot)
- [ ] 9.19: Verificar build + commit: `fase(9): auth module with better-auth and casl`
---
## Phase 10: Frontend — shadcn/ui Shell [PENDIENTE]
Spec: `.ralph/specs/phase-10-frontend-shell.md`
- [ ] 10.1: En frontend/: instalar shadcn/ui con `npx shadcn@latest init` (Vite, Zinc, CSS variables, Tailwind)
- [ ] 10.2: Instalar componentes shadcn: button, input, card, badge, dialog, dropdown-menu, command, sidebar, tabs, table, toast, form, separator, avatar, skeleton, tooltip, sheet, select, textarea, label, switch, alert
- [ ] 10.3: Instalar deps: `npm i @tanstack/react-query @tanstack/react-table zustand react-hook-form @hookform/resolvers framer-motion react-hotkeys-hook`
- [ ] 10.4: Crear layout: `components/layout/AppSidebar.tsx` — sidebar collapsible con nav items (Dashboard, Explorations, Findings, Reports, Settings)
- [ ] 10.5: Crear `components/layout/TopBar.tsx` — logo, search trigger (⌘K), theme toggle, user avatar menu
- [ ] 10.6: Crear `components/layout/AppLayout.tsx` — wrapper: Sidebar + TopBar + Content outlet
- [ ] 10.7: Crear `components/layout/CommandPalette.tsx` — ⌘K con shadcn Command component
- [ ] 10.8: Crear ThemeProvider: dark mode como default, toggle dark/light, persistir en localStorage
- [ ] 10.9: Crear `lib/api.ts` — API client con fetch, credentials: include, auto-redirect a /login en 401
- [ ] 10.10: Crear `lib/queryClient.ts` — TanStack Query provider
- [ ] 10.11: Crear `stores/uiStore.ts` — Zustand: sidebarCollapsed, theme
- [ ] 10.12: Crear pages/Login.tsx — form email + password con shadcn
- [ ] 10.13: Crear pages/Setup.tsx — wizard first-run (crear admin + nombre org)
- [ ] 10.14: Crear `components/layout/ProtectedRoute.tsx` — check auth, redirect a /login o /setup
- [ ] 10.15: Actualizar App.tsx con React Router: / (dashboard), /login, /setup, /sessions/:id, /findings/:id, /settings — todo wrapped en ProtectedRoute excepto login/setup
- [ ] 10.16: Verificar frontend build + commit: `fase(10): frontend shadcn-ui shell with auth`
---
## Phase 11: Dashboard Page [PENDIENTE]
Spec: `.ralph/specs/phase-11-dashboard.md`
- [ ] 11.1: Instalar en frontend: `npm i tremor recharts`
- [ ] 11.2: Crear `hooks/useFindings.ts` — TanStack Query hooks: useFindings, useFindingStats
- [ ] 11.3: Crear `hooks/useSessions.ts` — TanStack Query hooks: useSessions, useSession
- [ ] 11.4: Crear `hooks/useSocket.ts` — socket.io-client connection con auto-reconnect
- [ ] 11.5: Crear `components/dashboard/KPICards.tsx` — 4 cards Tremor: Total Findings, Critical/High, Active Sessions, Coverage
- [ ] 11.6: Crear `components/dashboard/TrendChart.tsx` — Recharts AreaChart stacked por severity, últimos 30 días
- [ ] 11.7: Crear `components/dashboard/SeverityDistribution.tsx` — Recharts PieChart con colores por severity
- [ ] 11.8: Crear `components/dashboard/RecentFindings.tsx` — TanStack Table, 10 rows, click → /findings/:id
- [ ] 11.9: Crear `components/dashboard/ActiveSessions.tsx` — lista con progress bars, click → /sessions/:id
- [ ] 11.10: Crear `components/dashboard/QuickActions.tsx` — botón "New Exploration" prominente
- [ ] 11.11: Crear `pages/Dashboard.tsx` — ensambla todo, responsive 2col desktop 1col mobile
- [ ] 11.12: Conectar real-time: socket events actualizan KPIs y recent findings
- [ ] 11.13: Verificar frontend build + commit: `fase(11): dashboard page with charts and realtime`
---
## Phase 12: Sessions Pages [PENDIENTE]
Spec: `.ralph/specs/phase-12-sessions-pages.md`
- [ ] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
- [ ] 12.7: Progress bar estados explorados / maxStates
- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
---
## Phase 13: Findings Pages [PENDIENTE]
Spec: `.ralph/specs/phase-13-findings-pages.md`
- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
---
## Phase 14: Settings Pages [PENDIENTE]
Spec: `.ralph/specs/phase-14-settings-pages.md`
- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
- [ ] 14.8: Section "License" — ver status licencia, input para activar key
- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
---
## Phase 15: Reporting Module [PENDIENTE]
Spec: `.ralph/specs/phase-15-reporting.md`
- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
- [ ] 15.2: Crear port: `IReportGenerator.ts`
- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
- [ ] 15.7: Integrar con job queue: generación async
- [ ] 15.8: Migración Kysely: tabla reports
- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
- [ ] 15.10: Tests: GenerateReportCommand con mock generator
- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
---
## Phase 16: Integrations Module [PENDIENTE]
Spec: `.ralph/specs/phase-16-integrations.md`
- [ ] 16.1: Instalar: `npm i @slack/web-api @octokit/rest`
- [ ] 16.2: Crear domain: `Integration.ts` (Entity), `WebhookEndpoint.ts` (Entity)
- [ ] 16.3: Crear value objects: `IntegrationType.ts` (jira/slack/github/webhook), `WebhookSecret.ts`
- [ ] 16.4: Crear port: `IIntegrationProvider.ts` (sendFinding)
- [ ] 16.5: Crear `infrastructure/webhooks/WebhookDispatcher.ts` — HMAC-SHA256 signature, retry con exponential backoff (3 intentos)
- [ ] 16.6: Crear `infrastructure/providers/SlackProvider.ts` — Block Kit message con severity, description, link
- [ ] 16.7: Crear `infrastructure/providers/GitHubIssuesProvider.ts` — crea issue con reproduction steps
- [ ] 16.8: Crear `infrastructure/providers/JiraProvider.ts` — REST API v3, crea issue con screenshots
- [ ] 16.9: Crear `event-handlers/OnFindingCreated.ts` — dispatch a todas las integrations activas
- [ ] 16.10: Crear `infrastructure/http/IntegrationsController.ts` — CRUD integrations + webhooks
- [ ] 16.11: Migración Kysely: tables integrations, webhook_endpoints, webhook_deliveries
- [ ] 16.12: Frontend: Settings/Integrations con forms por provider (Slack webhook URL, Jira config, GitHub token, custom webhook)
- [ ] 16.13: Tests: webhook dispatch + HMAC verification
- [ ] 16.14: Verificar build completo + commit: `fase(16): integrations module`
---
## Phase 17: Licensing Module [PENDIENTE]
Spec: `.ralph/specs/phase-17-licensing.md`
- [ ] 17.1: Crear domain: `License.ts` (Entity), value objects `LicensePlan.ts` (free/pro/enterprise), `FeatureEntitlement.ts`
- [ ] 17.2: Crear port: `ILicenseValidator.ts` (validate, getEntitlements)
- [ ] 17.3: Crear `infrastructure/RSALicenseValidator.ts` — verifica firma RSA-2048 con public key bundled
- [ ] 17.4: Crear feature flags: `FREE_FEATURES`, `PRO_FEATURES`, `ENTERPRISE_FEATURES` arrays
- [ ] 17.5: Crear `infrastructure/middleware/FeatureGateMiddleware.ts` — checkea feature en license antes de permitir request
- [ ] 17.6: Crear `infrastructure/http/LicensingController.ts` — POST /api/license/activate, GET /api/license/status
- [ ] 17.7: Crear `scripts/generate-license.ts` — CLI tool para generar license keys firmadas (uso interno)
- [ ] 17.8: Integrar gate checks en rutas Pro/Enterprise (reporting, integrations, etc.)
- [ ] 17.9: Frontend: License section en Settings
- [ ] 17.10: Tests: valid license passes, expired fails, wrong signature fails, feature gate blocks
- [ ] 17.11: Verificar build completo + commit: `fase(17): licensing module with RSA validation`
---
## Phase 18: CLI + CI/CD [PENDIENTE]
Spec: `.ralph/specs/phase-18-cli-cicd.md`
- [ ] 18.1: Instalar: `npm i commander`
- [ ] 18.2: Refactorizar `src/cli/abe.ts` con commander: comando `explore` con flags --url, --config (json file), --output (json|junit|markdown), --fail-on-severity, --api-key
- [ ] 18.3: Comando `abe report` — genera report de una sesión por id
- [ ] 18.4: Comando `abe status` — ping al servidor, muestra sessions activas
- [ ] 18.5: Output JUnit XML: cada finding = failing test, cada state sin findings = passing test
- [ ] 18.6: Exit codes: 0=clean, 1=findings over threshold, 2=error
- [ ] 18.7: Crear `.github/actions/abe-explore/action.yml` — GitHub Action composite
- [ ] 18.8: Crear `Dockerfile.ci` — imagen con Chromium para CI (basada en mcr.microsoft.com/playwright)
- [ ] 18.9: Crear `.github/workflows/abe-example.yml` — ejemplo completo
- [ ] 18.10: Actualizar README.md con sección CLI
- [ ] 18.11: Verificar build completo + commit: `fase(18): cli and cicd integration`
---
## Phase 19: Scheduling Module Refactor [PENDIENTE]
- [ ] 19.1: Migrar scheduling existente → nueva estructura modular (domain/application/infrastructure)
- [ ] 19.2: Crear Schedule aggregate con cron validation (Zod)
- [ ] 19.3: Integrar con job queue
- [ ] 19.4: Crear SchedulingController con CRUD + toggle
- [ ] 19.5: Frontend: Schedules section en Settings
- [ ] 19.6: Verificar build + commit: `fase(19): scheduling module refactor`
---
## Phase 20: Visual Regression Refactor [PENDIENTE]
- [ ] 20.1: Migrar visual regression existente → nueva estructura modular
- [ ] 20.2: Integrar con StorageProvider para screenshots
- [ ] 20.3: Refactorizar frontend /visual-review con shadcn/ui components
- [ ] 20.4: Verificar build + commit: `fase(20): visual regression refactor`
---
## Phase 21: API Documentation [PENDIENTE]
- [ ] 21.1: Instalar: `npm i @asteasolutions/zod-to-openapi @scalar/express-api-reference`
- [ ] 21.2: Crear Zod schemas compartidos para TODOS los endpoints (request + response)
- [ ] 21.3: Generar OpenAPI 3.1 spec desde Zod schemas
- [ ] 21.4: Montar Scalar UI en GET /api-docs
- [ ] 21.5: Servir spec JSON en GET /api-docs/openapi.json
- [ ] 21.6: Verificar que todos los endpoints están documentados
- [ ] 21.7: Verificar build + commit: `fase(21): openapi documentation with scalar`
---
## Phase 22: Docker Production [PENDIENTE]
- [ ] 22.1: Refactorizar Dockerfile backend: multi-stage, node:20-alpine, tini como init, non-root user, HEALTHCHECK
- [ ] 22.2: Refactorizar frontend Dockerfile: multi-stage build + nginx
- [ ] 22.3: Actualizar docker-compose.yml: healthcheck, restart policies, volumes, env_file
- [ ] 22.4: Crear docker-compose.prod.yml
- [ ] 22.5: Crear .dockerignore optimizado
- [ ] 22.6: CMD DEBE ser `["tini", "--", "node", "dist/main.js"]` — NUNCA npm
- [ ] 22.7: Verificar imagen final < 200MB
- [ ] 22.8: Verificar docker compose up funciona end-to-end
- [ ] 22.9: Commit: `fase(22): docker production setup`
---
## Phase 23: Observability [PENDIENTE]
- [ ] 23.1: Request correlation: requestId en CADA log entry via pino child logger
- [ ] 23.2: Structured error logging con contexto (userId, sessionId, etc.)
- [ ] 23.3: Liveness probe: GET /health/live
- [ ] 23.4: Readiness probe: GET /health/ready (DB + job queue check)
- [ ] 23.5: Startup probe: medir tiempo de arranque, loguear
- [ ] 23.6: Commit: `fase(23): observability and health probes`
---
## Phase 24: Onboarding + First-Run [PENDIENTE]
- [ ] 24.1: Detectar first-run en frontend (GET /api/auth/setup-required)
- [ ] 24.2: Wizard multi-step: paso 1 crear admin, paso 2 nombre org, paso 3 "Start your first exploration" con URL input
- [ ] 24.3: Empty states: ilustraciones/mensajes en tablas vacías ("No findings yet. Start an exploration!")
- [ ] 24.4: Commit: `fase(24): onboarding and first-run experience`
---
## Phase 25: Polish + Quality [PENDIENTE]
- [ ] 25.1: Audit TypeScript strict — eliminar TODOS los `any` restantes
- [ ] 25.2: Loading skeletons en todas las pages (shadcn Skeleton)
- [ ] 25.3: Error boundaries en cada page
- [ ] 25.4: Keyboard shortcuts: ⌘K (command palette), Esc (close dialogs), N (new exploration from dashboard)
- [ ] 25.5: Responsive mobile: sidebar collapse, tables scroll, forms stack
- [ ] 25.6: README.md profesional: badges (build, license, version), screenshots, features list, quick start, CLI docs, architecture diagram, contributing
- [ ] 25.7: CONTRIBUTING.md
- [ ] 25.8: LICENSE files: MIT para core, archivo LICENSE-ENTERPRISE separado
- [ ] 25.9: Commit: `fase(25): polish and quality improvements`
---
## Phase 26: SSO Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [ ] 26.1: SAML 2.0 via @node-saml/passport-saml con MultiSamlStrategy
- [ ] 26.2: OIDC via openid-client (Okta, Azure AD, Google Workspace)
- [ ] 26.3: Per-organization IdP configuration
- [ ] 26.4: LDAP/AD integration via passport-ldapauth
- [ ] 26.5: MFA (TOTP) support
- [ ] 26.6: Audit log completo (who did what, when)
- [ ] 26.7: Session management dashboard (ver/revocar sessions activas)
- [ ] 26.8: Feature-gated tras LICENSE enterprise
- [ ] 26.9: Commit: `fase(26): enterprise sso saml oidc ldap`
---
## Phase 27: Advanced Enterprise [PENDIENTE — ENTERPRISE ONLY]
- [ ] 27.1: Data retention policies (auto-delete findings > X days)
- [ ] 27.2: Backup/restore CLI tool
- [ ] 27.3: White-labeling (CSS custom properties + logo upload)
- [ ] 27.4: PostgreSQL support validado end-to-end
- [ ] 27.5: Email notifications (nodemailer + templates)
- [ ] 27.6: Kubernetes Helm chart
- [ ] 27.7: Commit: `fase(27): advanced enterprise features`

1
.ralph/progress.json Normal file
View File

@@ -0,0 +1 @@
{"status": "completed", "timestamp": "2026-03-04 04:32:44"}

View File

@@ -0,0 +1,130 @@
# ABE — AI Bug Report Enrichment Specification
## Concepto
Este es el diferenciador más importante de ABE frente a cualquier competidor.
Después de detectar una anomalía, ABE puede usar una LLM para enriquecer
el bug report con un análisis inteligente: causa probable, impacto,
sugerencia de fix, y prompt listo para usar con Claude/GPT.
## IMPORTANTE: esto es una capa OPCIONAL sobre el core determinista.
El core engine nunca llama a LLMs. El enriquecimiento es post-procesado,
ejecutado solo si el usuario lo configura.
## Qué genera la IA
### 1. Root Cause Analysis
A partir del action trace, HTTP log, console errors y DOM snapshot,
la IA propone la causa más probable del bug.
Ejemplo: "The 500 error is likely caused by missing server-side validation
of the email field. The server crashes when receiving an empty string
where a valid email is expected."
### 2. User Impact Assessment
La IA evalúa el impacto del bug en términos de negocio:
"This bug blocks users from completing registration. Any user who
submits an empty email will encounter an unhandled server error,
preventing account creation."
### 3. Suggested Fix
La IA propone un fix concreto:
"Add server-side validation: check if email is present and valid
before processing. Return a 422 with a descriptive error message
instead of propagating the exception."
### 4. AI-Ready Debug Prompt
Un prompt completo listo para copiar y pegar en Claude/ChatGPT:
```
Bug Report Context:
- Type: HTTP 500 on form submission
- Steps to reproduce: [exact action trace]
- Error: [exact error message]
- Request: POST /api/register with body {"email": ""}
- Response: 500 Internal Server Error
Please analyze this bug and provide:
1. Root cause
2. Code fix
3. Test case to prevent regression
```
## Implementación
### Provider abstraction
```typescript
interface IAIProvider {
name: string;
enrich(anomaly: IAnomaly, context: IEnrichmentContext): Promise<IAIEnrichment>;
}
interface IEnrichmentContext {
domSnapshot: string;
httpLog: IHttpResponse[];
consoleErrors: string[];
actionTrace: IAction[];
pageTitle: string;
url: string;
}
interface IAIEnrichment {
rootCause: string;
userImpact: string;
suggestedFix: string;
debugPrompt: string;
confidence: 'low' | 'medium' | 'high';
generatedAt: number;
provider: string;
model: string;
}
```
### Providers implementados
- `ClaudeProvider` — usa Anthropic API (claude-3-5-haiku — rápido y barato)
- `OpenAIProvider` — usa OpenAI API (gpt-4o-mini)
- `OllamaProvider` — usa Ollama local (llama3.2 — sin API key, offline)
### Cuándo se ejecuta
- Automático: si `aiEnrichment.autoEnrich: true`, se ejecuta tras cada anomalía high/critical
- Manual: botón "Enrich with AI" en AnomalyDetail page
- No bloquea: el bug report se guarda sin enriquecimiento, la IA lo añade async
## Configuración en .env
```
ABE_AI_PROVIDER=claude # claude | openai | ollama | none
ABE_AI_API_KEY=sk-ant-xxx # Anthropic key (si provider=claude)
ABE_OPENAI_API_KEY=sk-xxx # OpenAI key (si provider=openai)
ABE_OLLAMA_URL=http://localhost:11434 # (si provider=ollama)
ABE_AI_MODEL=claude-haiku-4-5 # modelo específico (opcional)
ABE_AI_AUTO_ENRICH=false # default false para no incurrir en costes
ABE_AI_MIN_SEVERITY=high # solo enriquecer high/critical automáticamente
```
## Modelo de datos — añadir a SQLite
### Añadir columna a anomalies
```sql
ALTER TABLE anomalies ADD COLUMN ai_enrichment_json TEXT;
ALTER TABLE anomalies ADD COLUMN ai_enriched_at INTEGER;
```
## Frontend — AI panel en AnomalyDetail
Si la anomalía tiene ai_enrichment_json, mostrar panel "AI Analysis" con:
- 🔍 Root Cause (texto con ícono)
- 👥 User Impact (texto con ícono)
- 🔧 Suggested Fix (bloque de código si contiene código)
- 📋 "Copy debug prompt" button (copia el debugPrompt al clipboard)
- Badge: "Analyzed by Claude" / "Analyzed by GPT-4o-mini" / "Analyzed by Llama 3.2"
- Timestamp de cuándo se generó
Si no tiene enriquecimiento, mostrar botón "✨ Analyze with AI" que llama a:
POST /api/anomalies/:id/enrich
## Endpoint nuevo
### POST /api/anomalies/:anomalyId/enrich
Dispara el enriquecimiento de una anomalía concreta (async).
Response inmediata: { status: 'enriching' }
Cuando termina, emite WebSocket event: anomaly:enriched { anomalyId, enrichment }
### GET /api/anomalies/:anomalyId — actualizado
Incluye ai_enrichment si está disponible.

View File

@@ -0,0 +1,59 @@
# ABE — API Security Specification
## Authentication: API Key
All API endpoints require an API key passed in the header:
`X-ABE-API-Key: <key>`
If missing or invalid → 401 Unauthorized.
## Configuration
API key is set via environment variable: `ABE_API_KEY`
If not set, server logs a warning and runs without auth (dev mode only).
## Implementation
Create `src/server/middleware/auth.ts`:
```typescript
export function apiKeyAuth(req, res, next) {
const apiKey = process.env.ABE_API_KEY;
if (!apiKey) return next(); // dev mode: no auth
const provided = req.headers['x-abe-api-key'];
if (!provided || provided !== apiKey) {
return res.status(401).json({ error: 'Invalid or missing API key' });
}
next();
}
```
Apply this middleware to ALL routes EXCEPT:
- GET /health
- GET /ready
## CORS
Only allow requests from the frontend origin.
Configure via environment variable: `ABE_CORS_ORIGIN` (default: `http://localhost:5173`)
## Rate Limiting
Add `express-rate-limit`:
- Max 20 POST /api/sessions per hour per IP
- Max 200 requests per minute per IP for other endpoints
## Environment Variables (full list for .env)
```
ABE_API_KEY=change-me-in-production
ABE_CORS_ORIGIN=http://localhost:5173
ABE_PORT=3001
ABE_DB_PATH=./data/abe.db
ABE_REPORTS_DIR=./reports
ABE_LOGS_DIR=./logs
NODE_ENV=production
```
## docker-compose update
Add .env file support and environment variables to docker-compose.yml.
Add a volumes entry for `data/` directory for SQLite persistence.

View File

@@ -0,0 +1,187 @@
# ABE — API Server Specification
## Arquitectura general
```
React (puerto 5173)
↕ HTTP REST + WebSocket
API Server Express (puerto 3001)
↕ imports directos
ExplorationEngine (core)
```
El servidor vive en `src/server/` y es el único punto de entrada al motor desde el exterior. El frontend NUNCA importa código del core directamente.
---
## Tecnología del servidor
- Framework: Express.js
- WebSocket: socket.io (para streaming en tiempo real)
- Archivos: `src/server/index.ts` y `src/server/routes/`
---
## REST Endpoints
### POST /api/sessions
Lanza una nueva exploración.
Request body:
```json
{
"url": "http://localhost:3000",
"seed": 42,
"maxStates": 50
}
```
Response:
```json
{
"sessionId": "sess_abc123",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z"
}
```
---
### GET /api/sessions
Lista todas las sesiones (activas e históricas).
Response:
```json
[
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "running",
"startedAt": "2025-01-15T10:00:00.000Z",
"anomaliesFound": 3,
"statesVisited": 12
}
]
```
---
### GET /api/sessions/:sessionId
Detalle de una sesión específica.
Response:
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"status": "completed",
"startedAt": "2025-01-15T10:00:00.000Z",
"finishedAt": "2025-01-15T10:05:00.000Z",
"statesVisited": 12,
"anomaliesFound": 3,
"seed": 42
}
```
---
### DELETE /api/sessions/:sessionId
Detiene una sesión activa.
Response:
```json
{ "stopped": true }
```
---
### GET /api/anomalies
Lista todas las anomalías encontradas (todas las sesiones).
Query params opcionales: `?sessionId=sess_abc123&severity=high`
Response:
```json
[
{
"id": "anom_a1b2c3",
"sessionId": "sess_abc123",
"type": "http_error",
"severity": "high",
"description": "Form returns HTTP 500 on empty email",
"timestamp": 1705312200000,
"screenshotUrl": "/api/anomalies/anom_a1b2c3/screenshot"
}
]
```
---
### GET /api/anomalies/:anomalyId
Detalle completo de una anomalía incluyendo pasos de reproducción.
Response: el objeto IAnomaly completo serializado (definido en interfaces.md)
---
### GET /api/anomalies/:anomalyId/screenshot
Devuelve la imagen PNG del screenshot de la anomalía.
Response: imagen binaria con Content-Type: image/png
---
### POST /api/anomalies/:anomalyId/replay
Lanza el replay de una anomalía específica.
Response:
```json
{
"replayId": "replay_xyz",
"status": "running"
}
```
---
## WebSocket Events (socket.io)
El cliente se conecta a `ws://localhost:3001` y escucha estos eventos:
### Eventos que emite el SERVIDOR → cliente
`session:started`
```json
{ "sessionId": "sess_abc123", "url": "http://localhost:3000" }
```
`state:discovered`
```json
{ "sessionId": "sess_abc123", "stateId": "s_xyz", "url": "/register", "title": "Register" }
```
`action:executed`
```json
{ "sessionId": "sess_abc123", "actionType": "click", "selector": "button#submit", "timestamp": 1705312197000 }
```
`anomaly:detected`
```json
{ "sessionId": "sess_abc123", "anomalyId": "anom_a1b2c3", "type": "http_error", "severity": "high", "description": "..." }
```
`session:completed`
```json
{ "sessionId": "sess_abc123", "statesVisited": 12, "anomaliesFound": 3 }
```
`session:error`
```json
{ "sessionId": "sess_abc123", "error": "Target URL unreachable" }
```
### Eventos que emite el CLIENTE → servidor
`session:stop`
```json
{ "sessionId": "sess_abc123" }
```

View File

@@ -0,0 +1,118 @@
# ABE — CLI & CI/CD Integration Specification
## CLI Entry Point
File: `src/cli.ts`
Script in package.json: `"abe": "ts-node src/cli.ts"`
Global after install: `npx abe` or `abe` if installed globally.
## CLI Usage
```bash
# Basic run
abe run --url http://localhost:3000
# With auth
abe run --url http://app.com \
--auth-type login_flow \
--login-url http://app.com/login \
--username test@app.com \
--password secret
# With scope limits
abe run --url http://app.com \
--max-states 30 \
--max-depth 4 \
--allowed-domains app.com
# CI mode: exit 1 if any anomaly found
abe run --url http://localhost:3000 --fail-on-anomaly
# CI mode: exit 1 only on high/critical anomalies
abe run --url http://localhost:3000 --fail-on-severity high
# Output formats
abe run --url http://localhost:3000 --output json # prints JSON summary to stdout
abe run --url http://localhost:3000 --output junit # generates junit.xml for CI
# Connect to a running ABE server instead of running inline
abe run --url http://localhost:3000 --server http://abe-server:3001 --api-key mykey
```
## Exit Codes
- 0 → exploration complete, no anomalies (or no anomalies above threshold)
- 1 → anomalies found above threshold
- 2 → exploration failed (target unreachable, auth failed, etc.)
## stdout JSON output (--output json)
```json
{
"sessionId": "sess_abc123",
"url": "http://localhost:3000",
"duration_ms": 45000,
"states_visited": 12,
"anomalies": [
{
"id": "anom_xyz",
"type": "http_error",
"severity": "high",
"description": "Form returns 500 on empty email",
"report_path": "reports/anom_xyz/report.json"
}
],
"exit_code": 1
}
```
## JUnit XML output (--output junit)
Generates `abe-results.xml` compatible with Jenkins, GitHub Actions, GitLab CI:
- Each anomaly = one failing test case
- Each explored state = one passing test case
## GitHub Actions Example Workflow
Create file: `.github/workflows/abe-example.yml` in the repo:
```yaml
name: ABE Exploratory Testing
on:
push:
branches: [main]
pull_request:
jobs:
explore:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start application
run: docker-compose up -d app
# assumes the project has a docker-compose with the target app
- name: Wait for app
run: npx wait-on http://localhost:3000 --timeout 30000
- name: Run ABE
run: |
npm install -g abe-explorer # or: npx abe
abe run \
--url http://localhost:3000 \
--max-states 30 \
--fail-on-severity high \
--output junit
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: abe-reports
path: reports/
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: abe-results.xml
```

View File

@@ -0,0 +1,99 @@
# ABE — Database Specification (SQLite)
## Rationale
File-based storage loses all data on container restart.
SQLite requires zero extra services and is perfect for self-hosted deployment.
## Library
Use `better-sqlite3` (synchronous, faster than async alternatives for this use case).
## Location
Database file: `data/abe.db` (persisted via Docker volume)
## Schema
### Table: sessions
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
```
### Table: states
```sql
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
```
### Table: actions
```sql
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
```
### Table: anomalies
```sql
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL
);
```
### Table: notifications
```sql
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
```
## Repository Pattern
Create `src/db/` with:
- `src/db/connection.ts` — singleton SQLite connection, runs migrations on startup
- `src/db/SessionRepository.ts` — CRUD for sessions
- `src/db/AnomalyRepository.ts` — CRUD for anomalies, includes filter by session/severity
- `src/db/migrations.ts` — runs all CREATE TABLE IF NOT EXISTS on startup
## Rules
- All DB operations are synchronous (better-sqlite3 is sync)
- Repositories are injected into the API server, never imported directly by core engine
- The engine emits events → the API server listens and persists to DB

View File

@@ -0,0 +1,102 @@
# ABE — Docker Specification
## Objetivo
Permitir arrancar todo el proyecto (backend + frontend) con un solo comando:
docker-compose up --build
## Backend Dockerfile (raíz del proyecto)
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3001
CMD ["node", "dist/server/index.js"]
```
## Frontend Dockerfile (frontend/Dockerfile)
Usa build multistage: primero compila con Node, luego sirve con nginx.
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
## nginx.conf (frontend/nginx.conf)
Necesario para que React Router funcione correctamente (todas las rutas apuntan a index.html):
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3001;
}
location /socket.io {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## docker-compose.yml (raíz)
```yaml
version: '3.8'
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
volumes:
- ./reports:/app/reports
- ./logs:/app/logs
networks:
- abe-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
depends_on:
- backend
networks:
- abe-network
networks:
abe-network:
driver: bridge
```
## Notas importantes
- El frontend en producción (nginx) hace proxy de /api y /socket.io al backend
- Los volúmenes reports/ y logs/ persisten datos entre reinicios del contenedor
- El frontend se accede en http://localhost:5173
- El backend se accede en http://localhost:3001

View File

@@ -0,0 +1,84 @@
# ABE — Exploration Scope & Target Authentication Specification
## Exploration Config Object
This config is passed via POST /api/sessions and stored in sessions.config_json.
```typescript
interface ExplorationConfig {
// Scope
allowedDomains: string[]; // e.g. ["localhost", "myapp.com"] — never follow external links
maxStates: number; // default: 50 — stop after this many unique states
maxDepth: number; // default: 5 — max click depth from start URL
actionDelayMs: number; // default: 500 — wait between actions (politeness)
sessionTimeoutMs: number; // default: 300000 (5 min) — hard stop
// Exclusions
excludedPaths: string[]; // e.g. ["/logout", "/admin"] — never navigate here
excludedSelectors: string[]; // e.g. ["button.delete", "a[href*='delete']"]
// Target authentication
auth: AuthConfig | null;
// Fuzzing
fuzzingEnabled: boolean; // default: true
fuzzingIntensity: 'low' | 'medium' | 'high'; // default: 'medium'
}
type AuthConfig =
| { type: 'cookies'; cookies: Array<{ name: string; value: string; domain: string }> }
| { type: 'headers'; headers: Record<string, string> }
| { type: 'login_flow'; loginUrl: string; usernameSelector: string; passwordSelector: string; submitSelector: string; username: string; password: string }
```
## Scope Rules (enforced in PlaywrightAgent)
1. Before navigating to any URL, check if hostname is in allowedDomains. If not, skip.
2. Before executing any action, check if current path matches excludedPaths. If yes, skip.
3. Before clicking any element, check if it matches excludedSelectors. If yes, skip.
4. Stop exploration when statesVisited >= maxStates OR depth >= maxDepth OR elapsed > sessionTimeoutMs.
## Authentication Flow
### type: 'cookies'
Inject cookies before the first navigation using playwright context.addCookies().
### type: 'headers'
Set extra HTTP headers on the browser context using context.setExtraHTTPHeaders().
### type: 'login_flow'
Before starting exploration:
1. Navigate to loginUrl
2. Fill usernameSelector with username
3. Fill passwordSelector with password
4. Click submitSelector
5. Wait for navigation to complete
6. Verify we are no longer on loginUrl (if still there, login failed → abort session with error)
7. Proceed with exploration from startUrl
## Updated POST /api/sessions request body
```json
{
"url": "http://localhost:3000",
"seed": 42,
"config": {
"allowedDomains": ["localhost"],
"maxStates": 50,
"maxDepth": 5,
"actionDelayMs": 500,
"sessionTimeoutMs": 300000,
"excludedPaths": ["/logout"],
"excludedSelectors": [],
"auth": {
"type": "login_flow",
"loginUrl": "http://localhost:3000/login",
"usernameSelector": "input[name='email']",
"passwordSelector": "input[name='password']",
"submitSelector": "button[type='submit']",
"username": "test@example.com",
"password": "password123"
},
"fuzzingEnabled": true,
"fuzzingIntensity": "medium"
}
}
```

View File

@@ -0,0 +1,72 @@
# ABE — Frontend v2 Specification
## New pages and components to add
### New Page: Settings (ruta: /settings)
Sections:
1. API Key — show current key, button to copy
2. Notifications — form to set Slack webhook URL and min severity (calls PATCH /api/config)
3. Default Exploration Config — form with default values for maxStates, maxDepth, delay, excluded paths
4. About — version, links to docs
### Updated: NewSessionForm
Add fields:
- Allowed Domains (chips input, default: hostname of URL)
- Max States (number, default 50)
- Max Depth (number, default 5)
- Action Delay ms (number, default 500)
- Excluded Paths (chips input)
- Auth Type (select: none / cookies / headers / login_flow)
- If login_flow: show loginUrl, usernameSelector, passwordSelector, submitSelector, username, password
- If cookies: textarea for JSON cookie array
- If headers: key-value pairs input
- Fuzzing enabled (toggle)
- Fuzzing intensity (select: low / medium / high)
### Updated: Dashboard
Add stats bar at the top with 4 numbers:
- Total sessions
- Total anomalies found
- Critical/High anomalies (highlighted in red)
- Sessions running now
### Updated: AnomalyList
Add filter bar:
- Filter by severity (multi-select: low, medium, high, critical)
- Filter by type (multi-select: http_error, js_exception, etc.)
- Filter by session (dropdown)
- Search by description (text input)
- Sort by: newest first / severity desc
### Updated: AnomalyDetail
Add:
- Download button → downloads report.json
- Download MD button → downloads report.md
- Copy replay command button → copies `abe replay --anomaly-id anom_xxx` to clipboard
### New Component: SeverityBadge
Reusable badge component used everywhere:
- critical → red bg, white text
- high → orange bg, white text
- medium → yellow bg, dark text
- low → blue bg, white text
### New API endpoints needed (add to api-server spec)
PATCH /api/config
- Updates server config (slack webhook, min severity, defaults)
- Body: Partial<ServerConfig>
- Returns: updated ServerConfig
GET /api/config
- Returns current server config (without API key value)
GET /api/stats
- Returns: { totalSessions, totalAnomalies, criticalHighCount, runningSessions }
- Used by dashboard stats bar

View File

@@ -0,0 +1,99 @@
# ABE — Frontend Specification
## Tecnología
- React 18 + TypeScript
- Vite (bundler, más simple que webpack)
- TailwindCSS (estilos sin escribir CSS manual)
- socket.io-client (WebSocket)
- React Router v6 (navegación entre páginas)
## Ubicación
El frontend vive en `frontend/` en la raíz del proyecto, completamente separado de `src/`.
```
frontend/
├── src/
│ ├── pages/
│ │ ├── Dashboard.tsx ← página principal
│ │ ├── SessionDetail.tsx ← detalle de una sesión en vivo
│ │ └── AnomalyDetail.tsx ← detalle de un bug report
│ ├── components/
│ │ ├── NewSessionForm.tsx ← formulario para lanzar exploración
│ │ ├── SessionList.tsx ← lista de sesiones
│ │ ├── AnomalyList.tsx ← lista de anomalías
│ │ ├── LiveFeed.tsx ← stream en tiempo real de eventos
│ │ └── AnomalyCard.tsx ← tarjeta de una anomalía
│ ├── hooks/
│ │ ├── useSocket.ts ← conexión WebSocket reutilizable
│ │ └── useApi.ts ← fetch helper para la API REST
│ ├── types.ts ← tipos TypeScript del frontend (espejo de interfaces.ts)
│ ├── App.tsx ← router principal
│ └── main.tsx ← entry point
├── index.html
├── vite.config.ts
├── tailwind.config.ts
└── package.json
```
---
## Página 1 — Dashboard (ruta: `/`)
Contiene:
- Botón "New Exploration" que abre el formulario
- `NewSessionForm`: campos URL y Seed, botón Start
- `SessionList`: tabla con todas las sesiones (estado, URL, anomalías encontradas, fecha)
- `AnomalyList`: lista de las últimas anomalías de todas las sesiones
---
## Página 2 — Session Detail (ruta: `/sessions/:sessionId`)
Contiene:
- Header con URL explorada, estado (running/completed), seed
- Botón "Stop" si la sesión está activa
- `LiveFeed`: lista en tiempo real de eventos WebSocket
- Cada evento muestra icono + texto + timestamp
- Scroll automático al último evento
- Colores: verde para state:discovered, amarillo para action:executed, rojo para anomaly:detected
- `AnomalyList`: anomalías encontradas en esta sesión (se actualiza en tiempo real)
---
## Página 3 — Anomaly Detail (ruta: `/anomalies/:anomalyId`)
Contiene:
- Header con tipo, severidad (badge de color), descripción
- Sección "Reproduction Steps": lista numerada de acciones
- Sección "Evidence":
- Screenshot a tamaño completo (imagen)
- Botón para ver DOM snapshot (abre en nueva pestaña)
- Sección "HTTP Log": tabla con requests (URL, método, status, duración)
- Sección "Raw Errors": bloque de código con los errores textuales
- Botón "Run Replay": llama a POST /api/anomalies/:id/replay y muestra estado
---
## Colores de severidad (badges)
- critical → rojo (#ef4444)
- high → naranja (#f97316)
- medium → amarillo (#eab308)
- low → azul (#3b82f6)
---
## Conexión con la API
Todas las llamadas van a `http://localhost:3001`.
En `vite.config.ts` configurar proxy para `/api` y `/socket.io` apuntando a `localhost:3001`.
```typescript
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/socket.io': { target: 'http://localhost:3001', ws: true }
}
}
})
```

View File

@@ -0,0 +1,94 @@
# ABE — Fuzzing / Disruption Module Specification
## Purpose
This is ABE's core differentiator. Instead of only clicking valid elements,
ABE injects abnormal inputs into forms to provoke unexpected server behavior.
## Architecture
```
src/plugins/fuzzers/
├── FuzzingEngine.ts ← orchestrator, decides when and how to fuzz
├── strategies/
│ ├── EmptyValueStrategy.ts
│ ├── OversizedStringStrategy.ts
│ ├── SpecialCharsStrategy.ts
│ ├── TypeMismatchStrategy.ts
│ └── BoundaryValueStrategy.ts
└── InputTypeDetector.ts ← detects field type from DOM attributes
```
## InputTypeDetector
Detects field type from: input[type], input[name], input[placeholder], label text, aria-label.
```typescript
type DetectedInputType =
| 'email' | 'password' | 'number' | 'date' | 'phone'
| 'url' | 'search' | 'text' | 'textarea' | 'select' | 'file'
```
## Fuzzing Strategies
### EmptyValueStrategy
Submits forms with all fields empty. Catches missing server-side validation.
Applies to: all input types.
Values: `""`, `" "` (space only), `"\t"` (tab).
### OversizedStringStrategy
Submits strings far beyond expected length. Catches buffer issues and UI overflow.
Applies to: text, email, password, textarea.
Values by intensity:
- low: 256 chars
- medium: 1024 chars
- high: 10000 chars + unicode chars
### SpecialCharsStrategy
Injects characters that break SQL, HTML, and shell contexts.
Applies to: text, email, search, textarea.
Values:
```
' OR 1=1 --
<script>alert(1)</script>
../../etc/passwd
${7*7}
\x00\x01\x02
```
### TypeMismatchStrategy
Submits wrong data types for the field.
- email field → "not-an-email", "12345", "@@@"
- number field → "abc", "-999999", "9.9.9", "NaN"
- date field → "yesterday", "32/13/2025", "0000-00-00"
- url field → "javascript:alert(1)", "not a url"
- phone field → "000", "++++", "abcdefghij"
### BoundaryValueStrategy
Tests values at the edges of expected ranges.
- number field → 0, -1, 2147483647, 2147483648, -2147483648
- date field → "1900-01-01", "2099-12-31", "1970-01-01"
## Fuzzing Execution Flow
```
For each form discovered in state:
1. InputTypeDetector analyzes each field
2. FuzzingEngine selects strategies based on fuzzingIntensity:
- low: EmptyValue + TypeMismatch only
- medium: + OversizedString + BoundaryValue
- high: + SpecialChars
3. For each strategy, fill all fields with fuzz values
4. Submit the form
5. Observe response via AnomalyDetector
6. Record results
```
## AnomalyDetector additions for fuzzing
Add these new anomaly types:
- `validation_bypass` — server accepted clearly invalid input (e.g. submitted empty required email, got 200)
- `server_error_on_fuzz` — server returned 500 on a fuzzed input
- `xss_reflection` — fuzzed script tag appears in response body
## Integration point
FuzzingEngine is called from ExplorationEngine AFTER normal action discovery,
only when `config.fuzzingEnabled === true`.
It is passed as an optional plugin, so the core engine doesn't depend on it directly.

View File

@@ -0,0 +1,164 @@
# ABE — Core Interfaces Specification
## Regla fundamental
`src/core/` solo puede importar desde este documento.
`src/plugins/` implementa estas interfaces, nunca al revés.
---
## IState
Representa un estado único de la aplicación explorada.
```typescript
interface IState {
id: string; // hash SHA1 del snapshot DOM + URL
url: string; // URL completa en este estado
title: string; // document.title
timestamp: number; // Date.now() cuando se capturó
domSnapshot: string; // outerHTML del body serializado
visitCount: number; // cuántas veces se ha visitado este estado
}
```
---
## IAction
Representa una acción que el agente puede ejecutar.
```typescript
interface IAction {
id: string; // uuid v4 generado al crear la acción
type: 'click' | 'fill' | 'navigate' | 'select' | 'submit';
selector?: string; // CSS selector del elemento (si aplica)
value?: string; // valor a introducir (para fill/select)
url?: string; // destino (solo para navigate)
timestamp: number; // cuando se ejecutó
seed: number; // semilla usada para selección aleatoria
stateId: string; // ID del estado desde el que se ejecutó
}
```
---
## IObservation
Lo que el agente observa DESPUÉS de ejecutar una acción.
```typescript
interface IObservation {
id: string; // uuid v4
actionId: string; // acción que provocó esta observación
newStateId: string; // ID del nuevo estado resultante
httpResponses: IHttpResponse[]; // todas las requests durante la acción
consoleErrors: string[]; // mensajes de console.error capturados
jsExceptions: string[]; // excepciones JS no capturadas
timestamp: number;
}
interface IHttpResponse {
url: string;
status: number;
method: string;
durationMs: number;
}
```
---
## IAnomaly
Una desviación detectada del comportamiento esperado.
```typescript
interface IAnomaly {
id: string; // uuid v4
type: AnomalyType;
severity: 'low' | 'medium' | 'high' | 'critical';
observationId: string; // observación que la provocó
actionTrace: IAction[]; // secuencia exacta de acciones que llevaron aquí
description: string; // texto legible explicando qué pasó
evidence: IAnomalyEvidence;
timestamp: number;
}
type AnomalyType =
| 'http_error' // respuesta HTTP 4xx o 5xx
| 'js_exception' // excepción JavaScript no capturada
| 'console_error' // console.error detectado
| 'navigation_fail' // navegación no completada
| 'element_missing' // elemento esperado desaparece
| 'timeout'; // acción excede tiempo límite
interface IAnomalyEvidence {
screenshotPath?: string; // ruta relativa al screenshot
domSnapshotPath?: string; // ruta relativa al DOM serializado
httpLog?: IHttpResponse[]; // requests relevantes
rawErrors?: string[]; // errores textuales originales
}
```
---
## IInteractionAgent (plugin interface)
Lo que cualquier agente de interacción debe implementar.
```typescript
interface IInteractionAgent {
launch(url: string): Promise<void>;
close(): Promise<void>;
discoverActions(state: IState): Promise<IAction[]>;
executeAction(action: IAction): Promise<IObservation>;
captureState(): Promise<IState>;
}
```
---
## ICollector (plugin interface)
Lo que cualquier colector de contexto debe implementar.
```typescript
interface ICollector {
name: string;
collect(anomaly: IAnomaly, agent: IInteractionAgent): Promise<IAnomalyEvidence>;
}
```
---
## IReproducer
Genera un script de replay a partir de una traza de acciones.
```typescript
interface IReproducer {
serialize(trace: IAction[]): string; // JSON serializado
deserialize(raw: string): IAction[]; // reconstruye la traza
generateScript(trace: IAction[]): string; // script Playwright ejecutable
}
```
---
## IExporter (plugin interface)
Transforma una anomalía en un reporte consumible.
```typescript
interface IExporter {
format: 'markdown' | 'json';
export(anomaly: IAnomaly, outputDir: string): Promise<string>; // retorna la ruta del archivo generado
}
```
---
## StateGraph
No es una interfaz pero su contrato debe ser explícito.
```typescript
class StateGraph {
addState(state: IState): void;
hasState(stateId: string): boolean;
recordTransition(fromId: string, action: IAction, toId: string): void;
getUnvisited(): IState[]; // estados con visitCount === 0
getNextToExplore(): IState | null; // heurística BFS por defecto
toJSON(): object; // serializable para logs
}
```

View File

@@ -0,0 +1,119 @@
# ABE — Multi-Browser, Mobile Emulation & Accessibility Specification
## Multi-browser testing
### Browsers soportados (via Playwright)
- chromium (Chrome/Edge) — siempre disponible
- firefox — opcional
- webkit (Safari) — opcional
### Configuración en ExplorationConfig
```typescript
browsers: Array<'chromium' | 'firefox' | 'webkit'>; // default: ['chromium']
```
### Comportamiento
Cuando se especifican múltiples browsers:
- ABE ejecuta la misma exploración en paralelo en cada browser
- Cada browser crea su propia sub-sesión con el mismo seed
- Los resultados se agrupan bajo la misma sesión padre
- Las anomalías incluyen qué browser las detectó
- Anomalías que aparecen en TODOS los browsers → severity += 1 level
- Anomalías que aparecen solo en un browser → añadir tag "browser-specific: webkit"
### Añadir a IAnomaly
```typescript
browser: 'chromium' | 'firefox' | 'webkit';
browserVersion: string;
```
---
## Mobile Viewport Emulation
### Devices predefinidos (usar Playwright devices)
```typescript
type MobileDevice =
| 'iPhone 14'
| 'iPhone 14 Pro Max'
| 'Pixel 7'
| 'Galaxy S23'
| 'iPad Pro'
| 'none' // desktop (default)
```
### En ExplorationConfig
```typescript
mobileDevice: MobileDevice; // default: 'none'
viewport: { width: number; height: number } | null; // override manual
```
### Implementación en PlaywrightAgent
```typescript
// Si mobileDevice !== 'none':
const device = playwright.devices[config.mobileDevice];
const context = await browser.newContext({ ...device });
```
### Anomalías específicas de mobile
Añadir tipo: `mobile_layout_issue` — detectado cuando:
- Un elemento clickable tiene menos de 44x44px (WCAG touch target)
- Hay scroll horizontal inesperado (viewport overflow)
- Un elemento está fuera del viewport en mobile
---
## Accessibility Testing (axe-core)
### Librería
Usar `@axe-core/playwright` (integración oficial axe + Playwright).
### Cuándo ejecutar
Después de cada acción que cambia el estado (navigation + click que resulta en nuevo estado).
NO ejecutar en cada acción fill (demasiado frecuente).
### Implementación
```typescript
import { checkA11y } from 'axe-playwright';
// En PlaywrightAgent, después de captureState():
async function runAccessibilityCheck(page: Page): Promise<IAccessibilityResult[]> {
const results = await checkA11y(page, undefined, {
detailedReport: true,
detailedReportOptions: { html: true },
});
return results.violations.map(v => ({
id: v.id,
impact: v.impact, // 'minor' | 'moderate' | 'serious' | 'critical'
description: v.description,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
selector: v.nodes[0]?.target?.join(', '),
}));
}
```
### Nuevo tipo de anomalía
- type: `accessibility_violation`
- severity mapping desde axe impact:
- minor → low
- moderate → medium
- serious → high
- critical → critical
- description: "[axe] {violation.description}"
- evidence: { helpUrl, affectedNodes, wcagCriteria }
### En ExplorationConfig
```typescript
accessibility: {
enabled: boolean; // default: true
minImpact: 'minor' | 'moderate' | 'serious' | 'critical'; // default: 'serious'
wcagLevel: 'A' | 'AA' | 'AAA'; // default: 'AA'
}
```
### En el bug report
Añadir sección "Accessibility Violations" en report.md con:
- Lista de violaciones con impact badge
- Link a la documentación de cada regla (helpUrl de axe)
- Selector CSS del elemento afectado

View File

@@ -0,0 +1,88 @@
# ABE — Network Chaos Specification
## Concepto
Inspirado en Gremlin y LitmusChaos, pero aplicado a nivel de browser.
ABE puede simular condiciones de red adversas durante la exploración
para descubrir cómo se comporta el app en redes lentas, intermitentes,
o con servicios externos fallando.
## Esto es diferente al fuzzing de inputs:
- Fuzzing: inputs inválidos en formularios
- Network chaos: condiciones de red adversas (latencia, pérdida de paquetes, timeout)
## Implementación via Playwright CDP
Playwright expone Chrome DevTools Protocol (CDP) que permite controlar la red:
```typescript
// En PlaywrightAgent
async function applyNetworkCondition(condition: NetworkCondition): Promise<void> {
const client = await this.page.context().newCDPSession(this.page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.downloadKbps * 1024 / 8,
uploadThroughput: condition.uploadKbps * 1024 / 8,
latency: condition.latencyMs,
});
}
```
## Perfiles de red predefinidos
```typescript
const NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null // sin limitación (default)
}
```
## API request interception (simular servicios caídos)
```typescript
// Simular que un endpoint específico falla con 503
await page.route('**/api/payment**', route => {
route.fulfill({ status: 503, body: 'Service Unavailable' });
});
// Simular latencia en un endpoint específico
await page.route('**/api/search**', async route => {
await new Promise(r => setTimeout(r, 3000)); // 3s delay
route.continue();
});
```
## Configuración en ExplorationConfig
```typescript
networkChaos: {
enabled: boolean; // default: false
profile: keyof typeof NETWORK_PROFILES; // default: 'none'
blockedEndpoints: string[]; // glob patterns — responden 503
slowEndpoints: Array<{
pattern: string; // glob
delayMs: number;
}>;
}
```
## Anomalías específicas de network chaos
Añadir tipos al AnomalyDetector:
- `offline_handling_missing` — app muestra pantalla en blanco o error no controlado cuando está offline
- `slow_network_no_feedback` — con slow-3g, la app no muestra loading indicator (detectado si CLS=0 pero LCP>5000ms y no hay elemento con rol 'progressbar' o 'status')
- `external_service_crash` — cuando un endpoint bloqueado causa error 500 en el frontend
## Integración con el flujo de exploración
NetworkChaos se aplica de forma secuencial, no simultánea:
1. Primera pasada: exploración normal (baseline)
2. Segunda pasada (si networkChaos.enabled): misma seed, con perfil de red aplicado
3. Comparar resultados: nuevas anomalías que aparecen solo en la segunda pasada son network-related
## Frontend — Network Chaos Config
En NewSessionForm, añadir sección collapsible "Network Chaos":
- Toggle "Enable network chaos"
- Select perfil: Fast 3G / Slow 3G / 2G / Offline
- Textarea "Blocked endpoints" (uno por línea, glob patterns)
- Lista "Slow endpoints" con campo pattern + delay ms

View File

@@ -0,0 +1,64 @@
# ABE — Notifications Specification
## Purpose
When ABE finds an anomaly autonomously, notify the team immediately.
## Supported Channels
### 1. Slack Webhook
Environment variable: `ABE_SLACK_WEBHOOK_URL`
Payload sent to Slack on anomaly:detected:
```json
{
"text": "🐛 ABE found a bug!",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*ABE Bug Report*\n*Severity:* 🔴 HIGH\n*Type:* http_error\n*Description:* Form returns HTTP 500 on empty email\n*Session:* sess_abc123\n*Target:* http://localhost:3000"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Report" },
"url": "http://localhost:5173/anomalies/anom_abc123"
}
]
}
]
}
```
Only send for severity: high or critical (configurable via `ABE_NOTIFY_MIN_SEVERITY`).
### 2. Generic Webhook
Environment variable: `ABE_WEBHOOK_URL`
POST request with the full IAnomaly object as JSON body.
Includes header: `X-ABE-Event: anomaly.detected`
## Implementation
Create `src/server/notifications/`:
- `NotificationService.ts` — main service, called after anomaly is persisted to DB
- `SlackNotifier.ts` — implements Slack webhook
- `WebhookNotifier.ts` — implements generic webhook
NotificationService.notify(anomaly) is called from the API server
after every anomaly:detected event from the engine.
## Configuration (environment variables)
```
ABE_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz
ABE_WEBHOOK_URL=https://myapp.com/webhooks/abe
ABE_NOTIFY_MIN_SEVERITY=high # low | medium | high | critical
```
## Notification record
Every notification attempt (success or failure) is saved to the notifications table in SQLite.
Failed notifications are retried once after 60 seconds.

View File

@@ -0,0 +1,130 @@
# ABE — Output Format Specification
Cada anomalía genera DOS archivos en `reports/{anomaly-id}/`:
---
## 1. report.json — Para consumo por AI y tooling
```json
{
"version": "1.0",
"generated_at": "2025-01-15T10:30:00.000Z",
"environment": {
"target_url": "http://localhost:3000",
"abe_version": "0.1.0",
"os": "linux",
"node_version": "20.x"
},
"anomaly": {
"id": "anom_a1b2c3d4",
"type": "http_error",
"severity": "high",
"description": "Form submission returns HTTP 500 on empty email field",
"timestamp": 1705312200000
},
"reproduction": {
"seed": 42,
"steps": [
{
"step": 1,
"action_type": "navigate",
"url": "http://localhost:3000/register",
"timestamp": 1705312195000
},
{
"step": 2,
"action_type": "fill",
"selector": "input[name='email']",
"value": "",
"timestamp": 1705312196000
},
{
"step": 3,
"action_type": "click",
"selector": "button[type='submit']",
"timestamp": 1705312197000
}
]
},
"evidence": {
"screenshot": "screenshot.png",
"dom_snapshot": "dom.html",
"http_log": [
{
"url": "http://localhost:3000/api/register",
"method": "POST",
"status": 500,
"duration_ms": 234
}
],
"console_errors": [],
"js_exceptions": []
}
}
```
---
## 2. report.md — Para lectura humana
El archivo Markdown debe tener exactamente esta estructura:
```markdown
# Bug Report — [tipo de anomalía] — [fecha]
## Summary
[Una frase describiendo qué pasó y dónde]
## Severity
[low | medium | high | critical] — [justificación en una frase]
## Reproduction Steps
1. Navigate to `[url]`
2. [acción 2]
3. [acción 3]
...
**Seed used**: `42`
**Replay command**: `npm run replay -- --report reports/anom_a1b2c3d4/report.json`
## Observed Behavior
[Qué ocurrió exactamente — errores, respuestas HTTP, mensajes]
## Evidence
- Screenshot: `reports/anom_a1b2c3d4/screenshot.png`
- DOM Snapshot: `reports/anom_a1b2c3d4/dom.html`
- HTTP Log: [tabla con las requests relevantes]
## Raw Errors
\`\`\`
[errores textuales tal cual aparecieron]
\`\`\`
```
---
## Estructura de carpetas de salida
```
reports/
└── anom_a1b2c3d4/
├── report.json ← estructurado para AI
├── report.md ← legible para humanos
├── screenshot.png ← captura en el momento de la anomalía
└── dom.html ← snapshot completo del DOM
logs/
└── session_20250115_103000.jsonl ← una línea JSON por evento
```
---
## Formato del log de sesión (.jsonl)
Cada línea es un objeto JSON independiente:
```jsonl
{"event":"session_start","timestamp":1705312190000,"seed":42,"target":"http://localhost:3000"}
{"event":"state_discovered","timestamp":1705312191000,"state_id":"s_abc123","url":"/","title":"Home"}
{"event":"action_executed","timestamp":1705312196000,"action_id":"act_xyz","type":"fill","selector":"input[name='email']","value":""}
{"event":"anomaly_detected","timestamp":1705312197000,"anomaly_id":"anom_a1b2c3d4","type":"http_error","severity":"high"}
{"event":"session_end","timestamp":1705312210000,"states_visited":3,"anomalies_found":1}
```

View File

@@ -0,0 +1,124 @@
# ABE — Performance Metrics Specification
## Concepto
Durante la exploración, ABE captura métricas de rendimiento de cada
estado visitado. Inspirado en Checkly y Datadog RUM.
Esto permite detectar anomalías de rendimiento además de errores funcionales.
## Métricas capturadas por estado
```typescript
interface IPerformanceMetrics {
stateId: string;
url: string;
timestamp: number;
// Navigation Timing (disponibles via Playwright)
ttfb: number; // Time to First Byte (ms)
domContentLoaded: number; // DOMContentLoaded event (ms)
loadComplete: number; // Load event (ms)
// Core Web Vitals (via web-vitals library injected)
lcp: number | null; // Largest Contentful Paint (ms)
cls: number | null; // Cumulative Layout Shift (score)
fid: number | null; // First Input Delay (ms) - solo tras interacción
inp: number | null; // Interaction to Next Paint (ms)
// Resource counts
totalRequests: number;
failedRequests: number;
totalTransferSize: number; // bytes
}
```
## Implementación
### TTFB, DOMContentLoaded, Load
Via `page.evaluate()` usando `performance.timing` después de navigation:
```typescript
const timing = await page.evaluate(() => ({
ttfb: performance.timing.responseStart - performance.timing.requestStart,
domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart,
}));
```
### Core Web Vitals
Inyectar el script de `web-vitals` (npm) en la página:
```typescript
await page.addScriptTag({ url: 'https://unpkg.com/web-vitals/dist/web-vitals.iife.js' });
const vitals = await page.evaluate(() => new Promise(resolve => {
const result = {};
webVitals.getLCP(m => result.lcp = m.value);
webVitals.getCLS(m => result.cls = m.value);
webVitals.getINP(m => result.inp = m.value);
setTimeout(() => resolve(result), 3000); // wait 3s for vitals
}));
```
## Anomalías de rendimiento (nuevos tipos)
Añadir al AnomalyDetector con umbrales basados en Core Web Vitals de Google:
| Métrica | Good | Needs Improvement | Poor (anomalía) |
|---------|---------|-------------------|-----------------|
| LCP | <2500ms | 2500-4000ms | >4000ms → high |
| CLS | <0.1 | 0.1-0.25 | >0.25 → medium |
| INP | <200ms | 200-500ms | >500ms → high |
| TTFB | <800ms | 800-1800ms | >1800ms → medium|
Tipo de anomalía: `performance_degradation`
## Modelo de datos — añadir a SQLite
### Table: performance_metrics
```sql
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
```
## Frontend — Performance tab
Añadir tab "Performance" en SessionDetail:
- Tabla con todos los estados visitados y sus métricas
- Columnas con color coded: verde/amarillo/rojo según umbrales de Google
- Gráfico de barras: LCP por estado (para identificar páginas lentas)
- Summary cards: peor LCP, peor CLS, peor TTFB de la sesión
## En el bug report
Si hay anomalía performance_degradation, añadir sección en report.md:
```
## Performance Issue
- LCP: 5200ms (threshold: 4000ms) ❌
- CLS: 0.08 ✅
- TTFB: 2100ms (threshold: 1800ms) ❌
- Total page size: 4.2MB
```
## Configuración
Añadir a ExplorationConfig:
```typescript
performance: {
enabled: boolean; // default: true
lcpThresholdMs: number; // default: 4000
clsThreshold: number; // default: 0.25
inpThresholdMs: number; // default: 500
ttfbThresholdMs: number; // default: 1800
}
```

View File

@@ -0,0 +1,77 @@
# ABE — Production Hardening Specification
## Health Endpoints (no auth required)
### GET /health
Returns 200 if server is up.
```json
{ "status": "ok", "version": "0.1.0", "uptime_seconds": 3600 }
```
### GET /ready
Returns 200 if server is ready to accept requests (DB connected, no critical errors).
Returns 503 if not ready.
```json
{ "status": "ready", "db": "connected", "active_sessions": 2 }
```
Used by Docker HEALTHCHECK and Kubernetes readiness probes.
## Docker improvements
### Backend Dockerfile
Add HEALTHCHECK:
```dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
```
### docker-compose.yml updates
- Add healthcheck to backend service
- Add `restart: unless-stopped` to both services
- Add `data/` volume for SQLite persistence
- Load `.env` file: `env_file: .env`
- Add `depends_on: backend: condition: service_healthy` to frontend
### .env.example file
Create `.env.example` in repo root with all variables and example values.
`.env` added to `.gitignore`.
## Error handling improvements
Global Express error handler in `src/server/index.ts`:
- Catch all unhandled errors
- Log with timestamp and stack trace
- Return consistent JSON error format:
```json
{ "error": "Internal server error", "code": "INTERNAL_ERROR", "timestamp": 1705312200000 }
```
Never expose stack traces in production (NODE_ENV=production).
## Graceful shutdown
On SIGTERM/SIGINT:
1. Stop accepting new sessions
2. Wait for active sessions to finish (max 30s)
3. Close DB connection
4. Exit 0
## Concurrency limits
- Max concurrent exploration sessions: configurable via `ABE_MAX_CONCURRENT_SESSIONS` (default: 3)
- If limit reached, POST /api/sessions returns 429 with:
```json
{ "error": "Max concurrent sessions reached", "active": 3, "limit": 3 }
```
## Logging improvements
Replace console.log with structured logger (use `pino`):
```typescript
log.info({ sessionId, url, event: 'session_started' }, 'Session started')
log.error({ anomalyId, error }, 'Failed to capture screenshot')
```
All logs go to stdout (Docker captures them).
Log level configurable via `ABE_LOG_LEVEL` env var (default: 'info').

View File

@@ -0,0 +1,138 @@
# ABE — Project Structure Specification
## Árbol completo de archivos a crear
```
abe/
├── src/
│ ├── core/
│ │ ├── interfaces.ts ← TODAS las interfaces (IState, IAction, etc.)
│ │ ├── StateGraph.ts ← implementación del grafo de estados
│ │ ├── ExplorationEngine.ts ← loop principal de exploración
│ │ └── AnomalyDetector.ts ← reglas heurísticas de detección
│ ├── plugins/
│ │ ├── agents/
│ │ │ └── PlaywrightAgent.ts ← implementa IInteractionAgent
│ │ ├── collectors/
│ │ │ ├── ScreenshotCollector.ts
│ │ │ ├── NetworkCollector.ts
│ │ │ └── DOMSnapshotCollector.ts
│ │ ├── exporters/
│ │ │ ├── MarkdownExporter.ts
│ │ │ └── JSONExporter.ts
│ │ └── reproducers/
│ │ └── PlaywrightReproducer.ts
│ └── index.ts ← punto de entrada, conecta todo
├── tests/
│ ├── core/
│ │ ├── StateGraph.test.ts
│ │ ├── ExplorationEngine.test.ts
│ │ └── AnomalyDetector.test.ts
│ └── plugins/
│ ├── agents/
│ │ └── PlaywrightAgent.test.ts
│ └── exporters/
│ ├── MarkdownExporter.test.ts
│ └── JSONExporter.test.ts
├── reports/ ← generado en runtime, ignorado por git
├── logs/ ← generado en runtime, ignorado por git
├── package.json
├── tsconfig.json
├── jest.config.ts
└── CLAUDE.md
```
---
## Reglas de importación — MUY IMPORTANTE
```
✅ PERMITIDO:
src/core/ExplorationEngine.ts → importa de src/core/interfaces.ts
src/plugins/agents/PlaywrightAgent.ts → importa de src/core/interfaces.ts
src/index.ts → importa de src/core/ Y src/plugins/
❌ PROHIBIDO:
src/core/ExplorationEngine.ts → importa de src/plugins/ (rompe el desacoplamiento)
src/plugins/agents/A.ts → importa de src/plugins/exporters/B.ts (plugins no se conocen entre sí)
```
---
## Cómo se conecta todo en src/index.ts
El archivo de entrada debe seguir este patrón:
```typescript
// src/index.ts
import { ExplorationEngine } from './core/ExplorationEngine';
import { StateGraph } from './core/StateGraph';
import { PlaywrightAgent } from './plugins/agents/PlaywrightAgent';
import { ScreenshotCollector } from './plugins/collectors/ScreenshotCollector';
import { NetworkCollector } from './plugins/collectors/NetworkCollector';
import { DOMSnapshotCollector } from './plugins/collectors/DOMSnapshotCollector';
import { JSONExporter } from './plugins/exporters/JSONExporter';
import { MarkdownExporter } from './plugins/exporters/MarkdownExporter';
import { PlaywrightReproducer } from './plugins/reproducers/PlaywrightReproducer';
const graph = new StateGraph();
const agent = new PlaywrightAgent();
const collectors = [new ScreenshotCollector(), new NetworkCollector(), new DOMSnapshotCollector()];
const exporters = [new JSONExporter(), new MarkdownExporter()];
const reproducer = new PlaywrightReproducer();
const engine = new ExplorationEngine({ graph, agent, collectors, exporters, reproducer });
engine.run({ url: process.argv[2] || 'http://localhost:3000', seed: 42 });
```
---
## package.json — scripts obligatorios
```json
{
"name": "abe",
"version": "0.1.0",
"scripts": {
"build": "tsc",
"test": "jest",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ tests/",
"explore": "ts-node src/index.ts",
"replay": "ts-node src/replay.ts"
}
}
```
---
## tsconfig.json — configuración base
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
```
---
## jest.config.ts — configuración base
```typescript
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
};
```

View File

@@ -0,0 +1,79 @@
# ABE — Scheduled Monitoring Specification
## Concepto
ABE puede ejecutar exploraciones de forma automática en intervalos definidos,
sin intervención humana. Esto convierte ABE de una herramienta manual
en un sistema de monitorización continua, al estilo Checkly.
## Modelo de datos — añadir a SQLite
### Table: schedules
```sql
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL, -- e.g. "0 */6 * * *" (every 6h)
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
```
## Expresiones cron soportadas (presets en la UI)
| Label | Cron |
|------------------|----------------|
| Every 15 minutes | */15 * * * * |
| Every hour | 0 * * * * |
| Every 6 hours | 0 */6 * * * |
| Every day at 2am | 0 2 * * * |
| Every Monday 9am | 0 9 * * 1 |
## Implementación
Usar `node-cron` para el scheduler.
Crear `src/server/scheduler/SchedulerService.ts`:
- En startup, carga todos los schedules con enabled=1 de la DB
- Registra un cron job por cada schedule
- Cuando dispara, llama internamente a POST /api/sessions con la config guardada
- Actualiza last_run_at y next_run_at en la DB después de cada disparo
- Si la sesión anterior sigue running, skip este tick y log warning
## API endpoints nuevos
### GET /api/schedules
Lista todos los schedules.
### POST /api/schedules
Crea un nuevo schedule.
Body:
```json
{
"name": "Production daily check",
"url": "https://myapp.com",
"config": { ... mismo ExplorationConfig ... },
"cronExpression": "0 2 * * *",
"enabled": true
}
```
### PATCH /api/schedules/:id
Actualiza o activa/desactiva un schedule.
### DELETE /api/schedules/:id
Elimina un schedule.
## Frontend — nueva sección en Settings
Añadir tab "Schedules" en /settings:
- Lista de schedules activos con: nombre, URL, cron, última ejecución, próxima ejecución, toggle activo/inactivo
- Botón "New Schedule" abre modal con: nombre, URL, config de exploración, selector de frecuencia (presets + custom cron)
- Badge "Running" si hay una sesión activa del schedule en este momento
## Notificaciones específicas de schedules
Cuando un schedule dispara una exploración y encuentra anomalías high/critical,
enviar notificación con el subject: "[SCHEDULED] ABE found bugs in {url}"

View File

@@ -0,0 +1,124 @@
# ABE — Visual Regression Testing Specification
## Concepto
ABE toma screenshots durante la exploración. En vez de solo guardarlos,
los compara contra una baseline aprobada para detectar cambios visuales
inesperados entre ejecuciones. Inspirado en Percy y Chromatic,
pero integrado directamente en el flujo de exploración autónoma.
## Cómo funciona
### Primera ejecución (sin baseline)
1. ABE explora el app, toma screenshots de cada estado descubierto
2. Todos los screenshots se marcan como "pending review" en la UI
3. El usuario aprueba o rechaza cada uno desde la GUI
4. Los aprobados se convierten en la BASELINE
### Ejecuciones posteriores
1. ABE explora el app, toma screenshots de cada estado
2. Para cada screenshot, busca la baseline correspondiente por state_id (hash DOM+URL)
3. Si no hay baseline: marcar como "new state", notificar
4. Si hay baseline: comparar usando pixelmatch (npm library)
5. Si diff > threshold (default 0.1%): crear anomalía tipo visual_regression
6. Si diff <= threshold: marcar como "passed"
## Librería de comparación
Usar `pixelmatch` (npm) para comparación pixel a pixel.
Usar `sharp` para resize y normalización de imágenes antes de comparar.
```typescript
import pixelmatch from 'pixelmatch';
import sharp from 'sharp';
async function compareScreenshots(
baselinePath: string,
currentPath: string,
diffOutputPath: string,
threshold: number = 0.1
): Promise<{ diffPixels: number; diffPercent: number; hasDiff: boolean }> {
// resize both to same dimensions, compare, generate diff image
}
```
## Modelo de datos — añadir a SQLite
### Table: visual_baselines
```sql
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
```
### Table: visual_comparisons
```sql
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL, -- 'passed' | 'failed' | 'new_state' | 'pending'
created_at INTEGER NOT NULL
);
```
## Nuevo tipo de anomalía
Añadir a AnomalyDetector:
- type: `visual_regression`
- severity: calculado por diff_percent:
- < 1% → low
- 1-5% → medium
- 5-15% → high
- > 15% → critical
- description: "Visual regression detected: X% of pixels changed"
- evidence: baseline screenshot + current screenshot + diff image (highlighted in red)
## Nuevo endpoint de API
### GET /api/visual/comparisons
Lista todas las comparaciones de la sesión más reciente.
Query: ?status=failed&sessionId=xxx
### POST /api/visual/baselines/:comparisonId/approve
Aprueba un screenshot como nueva baseline.
### POST /api/visual/baselines/:comparisonId/reject
Rechaza (anomalía confirmada, no actualizar baseline).
### POST /api/visual/baselines/approve-all
Aprueba todos los "new_state" pendientes de una sesión.
## Frontend — nueva sección Visual Review
Nueva página /visual-review:
- Grid de cards, cada una muestra: URL del estado, thumbnail del screenshot actual
- Filtros: passed | failed | new_state | pending
- Click en una card abre modal con:
- Vista lado a lado: baseline izquierda, actual derecha
- Vista diff: imagen con píxeles cambiados en rojo
- Porcentaje de cambio
- Botones: Approve as new baseline | Mark as bug | Ignore
- Bulk actions: "Approve all new states", "Mark all failed as bugs"
## Configuración
Añadir a ExplorationConfig:
```typescript
visualRegression: {
enabled: boolean; // default: true
threshold: number; // default: 0.001 (0.1%)
screenshotFullPage: boolean; // default: false (solo viewport)
ignoreSelectors: string[]; // e.g. [".timestamp", ".ad-banner"] — excluir zonas dinámicas
}
```

View File

@@ -0,0 +1,134 @@
# Phase 1: Shared Domain — Building Blocks
## Objetivo
Crear las clases base que TODOS los módulos usarán. Esto es el cimiento.
## Result.ts
```typescript
// Discriminated union, no classes
type ResultOk<T> = { readonly ok: true; readonly value: T };
type ResultErr<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = ResultOk<T> | ResultErr<E>;
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
export function isOk<T, E>(r: Result<T, E>): r is ResultOk<T> { return r.ok; }
export function isErr<T, E>(r: Result<T, E>): r is ResultErr<E> { return !r.ok; }
```
## UniqueId.ts
```typescript
import { v4 as uuidv4 } from 'uuid';
export class UniqueId {
private constructor(private readonly value: string) {}
static create(): UniqueId { return new UniqueId(uuidv4()); }
static from(value: string): UniqueId { return new UniqueId(value); }
toString(): string { return this.value; }
equals(other?: UniqueId): boolean {
if (!other) return false;
return this.value === other.value;
}
}
```
## Entity.ts
```typescript
export abstract class Entity<T> {
protected readonly _id: UniqueId;
protected props: T;
constructor(props: T, id?: UniqueId) {
this._id = id ?? UniqueId.create();
this.props = props;
}
get id(): UniqueId { return this._id; }
equals(other?: Entity<T>): boolean {
if (!other) return false;
return this._id.equals(other._id);
}
}
```
## AggregateRoot.ts
```typescript
export abstract class AggregateRoot<T> extends Entity<T> {
private _domainEvents: DomainEvent[] = [];
get domainEvents(): ReadonlyArray<DomainEvent> {
return this._domainEvents;
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
clearEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
```
## ValueObject.ts
```typescript
export abstract class ValueObject<T> {
protected readonly props: T;
constructor(props: T) {
this.props = Object.freeze(props);
}
equals(other?: ValueObject<T>): boolean {
if (!other) return false;
return JSON.stringify(this.props) === JSON.stringify(other.props);
}
}
```
## DomainEvent.ts
```typescript
export interface DomainEvent {
readonly eventId: string;
readonly eventName: string;
readonly aggregateId: string;
readonly occurredOn: Date;
readonly payload: Record<string, unknown>;
}
```
## UseCase.ts
```typescript
export interface UseCase<TRequest, TResponse, TError = Error> {
execute(request: TRequest): Promise<Result<TResponse, TError>>;
}
```
## EventBus.ts + EventHandler.ts
```typescript
// EventBus.ts
export interface EventBus {
publish(event: DomainEvent): Promise<void>;
subscribe(eventName: string, handler: EventHandler): void;
}
// EventHandler.ts
export interface EventHandler {
handle(event: DomainEvent): Promise<void>;
}
```
## Tests requeridos (mínimo)
1. Result: Ok crea value accesible, Err crea error accesible, isOk/isErr discriminan
2. UniqueId: create genera string válido, equals funciona, from preserva valor
3. Entity: equals compara por id (no por props)
4. ValueObject: equals compara por props, props son inmutables
## IMPORTANTE
- Estos archivos NO importan NADA externo excepto 'uuid'
- NO usar decorators
- NO usar classes abstractas complicadas — mantener simple
- Cada archivo exporta UNA cosa principal

View File

@@ -0,0 +1,136 @@
# Phase 2: Shared Infrastructure
## Config.ts
Usa Zod para validar TODAS las env vars al arranque. Si falla → crash inmediato con mensaje claro.
```typescript
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const configSchema = z.object({
port: z.coerce.number().default(3001),
host: z.string().default('0.0.0.0'),
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
db: z.object({
driver: z.enum(['sqlite', 'postgres']).default('sqlite'),
path: z.string().default('./data/abe.db'),
url: z.string().optional(),
}),
auth: z.object({
secret: z.string().min(16).default('abe-dev-secret-change-in-prod'),
sessionMaxAge: z.coerce.number().default(86400),
}),
storage: z.object({
driver: z.enum(['local', 's3']).default('local'),
path: z.string().default('./data/storage'),
}),
cors: z.object({ origin: z.string().default('http://localhost:5173') }),
log: z.object({ level: z.enum(['debug','info','warn','error']).default('info') }),
api: z.object({
key: z.string().default('abe-dev-key-123'),
rateLimitWindowMs: z.coerce.number().default(900000),
rateLimitMax: z.coerce.number().default(100),
}),
ai: z.object({
provider: z.enum(['claude','openai','ollama','none']).default('none'),
apiKey: z.string().default(''),
autoEnrich: z.coerce.boolean().default(false),
minSeverity: z.enum(['low','medium','high','critical']).default('high'),
}),
jobs: z.object({
maxConcurrentSessions: z.coerce.number().default(3),
pollIntervalMs: z.coerce.number().default(1000),
}),
license: z.object({ key: z.string().default('') }),
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(): AppConfig {
// Map env vars to schema shape, parse
}
```
## Logger.ts
```typescript
import pino from 'pino';
export function createLogger(config: { level: string; nodeEnv: string }): pino.Logger {
return pino({
level: config.level,
transport: config.nodeEnv === 'development'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});
}
export type Logger = pino.Logger;
```
## DatabaseConnection.ts
```typescript
import { Kysely, SqliteDialect } from 'kysely';
import SQLite from 'better-sqlite3';
// Define Database interface con todas las tablas
export interface Database {
sessions: SessionTable;
states: StateTable;
actions: ActionTable;
anomalies: AnomalyTable;
// ... más tablas se añaden en fases posteriores
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
if (config.driver === 'postgres') {
// Import dinámico de pg para no requerir en SQLite
const { Pool } = require('pg');
const { PostgresDialect } = require('kysely');
return new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }),
});
}
// Crear directorio data/ si no existe
const path = require('path');
const fs = require('fs');
fs.mkdirSync(path.dirname(config.path), { recursive: true });
return new Kysely<Database>({
dialect: new SqliteDialect({ database: new SQLite(config.path) }),
});
}
```
## InProcessEventBus.ts
```typescript
import { EventEmitter } from 'events';
// Implements EventBus interface from shared/application
// Logging de cada evento publicado
// Catch errors en handlers (log pero no crash)
// setMaxListeners(50)
```
## StorageProvider.ts
```typescript
export interface IStorageProvider {
save(relativePath: string, data: Buffer): Promise<string>;
get(relativePath: string): Promise<Buffer | null>;
delete(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
// LocalStorageProvider: usa fs.promises, base path = config.storage.path
// Crea directorios automáticamente con mkdir recursive
```
## Migración 001
Crea las tablas que ya existen en el schema actual (sessions, states, actions, anomalies, notifications).
Usar `CREATE TABLE IF NOT EXISTS` para idempotencia.
Los tipos de columna deben coincidir con lo que ya tiene better-sqlite3.
## IMPORTANTE
- Config DEBE fallar rápido si hay env vars inválidas
- Logger NUNCA debe usar console.log
- Database factory NUNCA importa pg a menos que driver sea postgres
- EventBus handlers que fallan se loguean pero NO crashean el bus

View File

@@ -0,0 +1,216 @@
# Phase 7: API Server Refactor + Composition Root
## Middleware stack (ORDEN IMPORTA)
```typescript
// server.ts
export function createServer(deps: ServerDependencies): Express {
const app = express();
// 1. Request ID (PRIMERO — todo log necesita esto)
app.use(requestIdMiddleware);
// 2. Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
connectSrc: ["'self'", "ws:", "wss:"],
scriptSrc: ["'self'", "'unsafe-inline'"], // para Scalar docs
},
},
}));
// 3. CORS
app.use(cors({
origin: deps.config.cors.origin,
credentials: true,
}));
// 4. Rate limiting global
app.use(rateLimit({
windowMs: deps.config.api.rateLimitWindowMs,
max: deps.config.api.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
}));
// 5. Body parsing
app.use(express.json({ limit: '10mb' }));
// 6. Health endpoints (SIN auth)
app.get('/health/live', (_, res) => res.json({ status: 'ok' }));
app.get('/health/ready', async (_, res) => { /* check DB */ });
// 7. Auth routes (SIN auth middleware general)
app.use('/api/auth', deps.authController.router);
// 8. Auth middleware (TODOS los /api/ a partir de aquí)
app.use('/api', deps.authMiddleware);
// 9. Module routes
app.use('/api', deps.crawlingController.router);
app.use('/api', deps.findingsController.router);
app.use('/api', deps.fuzzingController.router);
// ... más módulos
// 10. 404 handler
app.use(notFoundMiddleware);
// 11. Error handler (SIEMPRE último)
app.use(globalErrorHandler);
return app;
}
```
## Error hierarchy
```typescript
export class AppError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly isOperational = true,
) { super(message); }
}
export class ValidationError extends AppError {
constructor(message: string, public readonly details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'AUTHENTICATION_ERROR');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
export class RateLimitError extends AppError {
constructor() {
super('Too many requests', 429, 'RATE_LIMIT');
}
}
```
## Global error handler
```typescript
export function globalErrorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
const logger = req.log || console; // pino child logger
if (err instanceof AppError && err.isOperational) {
logger.warn({ err, statusCode: err.statusCode }, err.message);
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
...(err instanceof ValidationError && err.details ? { details: err.details } : {}),
});
}
// Programmer error — log full stack, return generic message
logger.error({ err }, 'Unhandled error');
return res.status(500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
code: 'INTERNAL_ERROR',
});
}
```
## Composition root (main.ts)
```typescript
async function bootstrap() {
// 1. Config
const config = loadConfig();
// 2. Logger
const logger = createLogger(config);
logger.info({ port: config.port }, 'Starting ABE...');
// 3. Database + migrations
const db = createDatabase(config.db);
await runMigrations(db, logger);
// 4. Event bus
const eventBus = new InProcessEventBus(logger);
// 5. Storage
const storage = new LocalStorageProvider(config.storage.path);
// 6. Repositories
const sessionRepo = new KyselyCrawlSessionRepository(db);
const findingRepo = new KyselyFindingRepository(db);
// ... etc
// 7. Use cases
const startCrawl = new StartCrawlCommand(sessionRepo, eventBus);
const listFindings = new ListFindingsQuery(findingRepo);
// ... etc
// 8. Event handlers — subscribe to event bus
const onAnomalyDetected = new OnAnomalyDetected(findingRepo, eventBus);
eventBus.subscribe('crawling.anomaly_detected', onAnomalyDetected);
// ... etc
// 9. Controllers
const crawlingController = new CrawlingController(startCrawl, ...);
const findingsController = new FindingsController(listFindings, ...);
// ... etc
// 10. HTTP server
const app = createServer({ config, authMiddleware, crawlingController, findingsController, ... });
const httpServer = createServer(app);
// 11. Socket.io
const io = new Server(httpServer, { cors: { origin: config.cors.origin } });
const gateway = new SocketGateway(io, eventBus);
// 12. Job queue
const jobQueue = new SQLiteJobQueue(db, logger);
jobQueue.start();
// 13. Listen
httpServer.listen(config.port, config.host, () => {
logger.info({ port: config.port }, 'ABE server ready');
});
// 14. Graceful shutdown
async function shutdown(signal: string) {
logger.info({ signal }, 'Shutting down...');
httpServer.close();
io.close();
jobQueue.pause();
await jobQueue.waitForActive(30000);
await db.destroy();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Fatal: failed to start ABE', err);
process.exit(1);
});
```
## IMPORTANTE
- El código existente en src/server/ debe DEJAR DE USARSE gradualmente
- Mantener los endpoints viejos funcionando durante la migración
- Cada controller es una clase con un `.router` getter que retorna Express.Router
- NUNCA meter lógica de negocio en controllers — solo parse request → call use case → format response

View File

@@ -0,0 +1,66 @@
# Phase 8: Job Queue System
## Tabla jobs (SQLite)
```sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
payload TEXT NOT NULL,
result TEXT,
error TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
priority INTEGER NOT NULL DEFAULT 0,
run_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_jobs_poll ON jobs(status, run_at, priority DESC);
```
## Interface
```typescript
export interface IJobQueue {
enqueue<T>(type: string, payload: T, opts?: { runAt?: Date; priority?: number; maxAttempts?: number }): Promise<string>;
start(): void;
pause(): void;
waitForActive(timeoutMs: number): Promise<void>;
}
```
## Polling logic
```
loop (cada pollIntervalMs):
SELECT id, type, payload FROM jobs
WHERE status = 'pending' AND run_at <= datetime('now')
ORDER BY priority DESC, created_at ASC
LIMIT 1
if found:
UPDATE jobs SET status = 'running', started_at = now, attempts = attempts + 1
WHERE id = ? AND status = 'pending' // optimistic lock
if updated 0 rows → skip (otro worker lo tomó)
try:
result = await executeJob(type, payload)
UPDATE jobs SET status = 'completed', result = ?, completed_at = now
catch:
if attempts >= max_attempts:
UPDATE jobs SET status = 'failed', error = ?
else:
backoff = min(1000 * 2^attempts, 60000)
UPDATE jobs SET status = 'pending', run_at = now + backoff, error = ?
```
## Job types
- `exploration:run` — payload: { sessionId, config }
- `report:generate` — payload: { reportId, format, filters }
- `cleanup:old-data` — payload: { retentionDays }
## NO usar Redis
El job queue es SQLite-based para zero-dependency self-hosted.
Es simple, funciona para el volumen esperado (decenas de jobs, no miles).

View File

@@ -0,0 +1,148 @@
# Phase 9: Auth Module
## Objetivo
Sistema completo de autenticación y autorización para ABE como plataforma.
## Roles y permisos
| Role | Sessions | Findings | Reports | Integrations | Org/Users | Settings | License |
|---------|----------|----------|---------|--------------|-----------|----------|---------|
| owner | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD | CRUD |
| admin | CRUD | CRUD | CRUD | CRUD | CRU | CRUD | R |
| member | CR | CRU | CR | R | R | R | R |
| viewer | R | R | R | R | R | R | R |
## Better Auth config
```typescript
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
database: {
// Usar Kysely adapter o direct SQLite
type: 'sqlite',
url: config.db.path,
},
emailAndPassword: { enabled: true },
session: {
maxAge: config.auth.sessionMaxAge,
updateAge: 60 * 60, // refresh cada hora
},
// Organization plugin si disponible, sino implementar manual
});
```
Si Better Auth no soporta organizaciones directamente, implementar manualmente:
- Tabla organizations (id, name, slug, created_at)
- Tabla org_members (id, org_id, user_id, role, invited_at, joined_at)
## CASL AbilityFactory
```typescript
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
export function defineAbilityFor(role: string) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
switch (role) {
case 'owner':
can('manage', 'all');
break;
case 'admin':
can('manage', 'all');
cannot('delete', 'Organization');
cannot('manage', 'License');
can('read', 'License');
break;
case 'member':
can('create', ['Session', 'Finding', 'Report']);
can('read', 'all');
can('update', 'Finding');
break;
case 'viewer':
can('read', 'all');
break;
}
return build();
}
```
## AuthMiddleware — orden de verificación
1. Check cookie de session (web UI) via Better Auth
2. Check header `Authorization: Bearer <jwt>`
3. Check header `X-ABE-API-Key: <key>` (API keys para CI/CD)
4. Si ninguno → 401
## API Key system
- POST /api/auth/api-keys — crear key (retorna key UNA vez, después solo hash)
- GET /api/auth/api-keys — listar (sin mostrar key, solo nombre + último uso)
- DELETE /api/auth/api-keys/:id — revocar
- Keys hasheadas con SHA-256 en DB
- Cada key tiene: name, permissions (array de roles), expiresAt, lastUsedAt
## First-run flow
1. Backend: si tabla users tiene 0 rows → flag `setupRequired = true`
2. GET /api/auth/setup-required → `{ required: boolean }`
3. Si required, POST /api/auth/setup con { email, password, name, orgName }
4. Crea user con role owner + organization default
5. Después de setup, requiere login normal
## Migraciones
```sql
-- users (Better Auth maneja su propia tabla, pero añadir campos custom)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS org_members (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
user_id TEXT NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'member',
joined_at INTEGER NOT NULL,
UNIQUE(org_id, user_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL, -- primeros 8 chars para identificar
permissions TEXT NOT NULL DEFAULT '["member"]',
expires_at INTEGER,
last_used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
```
## NOTA sobre Better Auth
Si Better Auth resulta demasiado complejo de integrar con Express puro
o tiene incompatibilidades, implementar auth manualmente:
- argon2 para hash passwords
- crypto.randomUUID() para session tokens
- Cookie httpOnly + secure + sameSite para sessions
- Middleware custom que lee cookie → busca en auth_sessions → adjunta user a req
Esto es PERFECTAMENTE VÁLIDO. No over-engineer la auth.
La prioridad es que funcione, sea seguro, y tenga RBAC.

View File

@@ -0,0 +1,129 @@
# Phase 10: Frontend Shell — shadcn/ui
## Setup shadcn/ui
```bash
cd frontend
npx shadcn@latest init
# Responder: Vite, Zinc, CSS variables, YES to tailwind
```
## Layout principal
```
┌────────────────────────────────────────────────┐
│ TopBar: [☰] [ABE logo] ···· [⌘K Search] [🌙] [👤]│
├────────┬───────────────────────────────────────┤
│ │ │
│ Side- │ Content Area │
│ bar │ (React Router Outlet) │
│ │ │
│ 📊 Dashboard │
│ 🔍 Explorations │
│ 🐛 Findings │
│ 📄 Reports │
│ ───────── │
│ ⚙️ Settings │
│ │ │
└────────┴───────────────────────────────────────┘
```
## Dark mode (DEFAULT)
- Usar estrategia class-based: `<html class="dark">`
- CSS variables de shadcn ya soportan dark mode
- Toggle en TopBar: sol/luna
- Persistir en localStorage key 'abe-theme'
## Auth flow en frontend
```
App monta →
GET /api/auth/setup-required
→ si required: mostrar /setup
→ si no:
GET /api/auth/me
→ si 401: redirect /login
→ si ok: render AppLayout con user data
```
## API client (lib/api.ts)
```typescript
const API_URL = import.meta.env.VITE_API_URL || '';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...init,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `HTTP ${res.status}`);
}
return res.json();
}
```
## Routing
```typescript
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route path="/" element={<Dashboard />} />
<Route path="/sessions" element={<SessionList />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/findings" element={<FindingsList />} />
<Route path="/findings/:id" element={<FindingDetail />} />
<Route path="/reports" element={<Reports />} />
<Route path="/visual-review" element={<VisualReview />} />
<Route path="/settings/*" element={<Settings />} />
</Route>
</Routes>
```
## Command Palette (⌘K)
Powered by shadcn Command (cmdk):
- Buscar por: sessions, findings, settings sections
- Acciones: "New Exploration", "Generate Report"
- Keyboard: ⌘K abre, Esc cierra
## File structure
```
frontend/src/
├── components/
│ ├── ui/ # shadcn generados (NO tocar)
│ ├── layout/
│ │ ├── AppLayout.tsx
│ │ ├── AppSidebar.tsx
│ │ ├── TopBar.tsx
│ │ ├── CommandPalette.tsx
│ │ ├── ProtectedRoute.tsx
│ │ └── ThemeProvider.tsx
│ └── common/
│ └── SeverityBadge.tsx
├── hooks/
│ ├── useAuth.ts
│ └── useSocket.ts
├── lib/
│ ├── api.ts
│ ├── queryClient.ts
│ └── utils.ts # cn() de shadcn
├── stores/
│ └── uiStore.ts
├── pages/
│ ├── Dashboard.tsx (placeholder "Coming in Phase 11")
│ ├── Login.tsx
│ └── Setup.tsx
├── App.tsx
└── main.tsx
```
## IMPORTANTE
- El Dashboard en esta fase puede ser un placeholder que diga "Dashboard — Coming soon"
- Lo importante es que el shell funcione: login → sidebar → routing → theme
- NO intentar hacer todo el dashboard aquí — eso es Phase 11

59
CLAUDE.md Normal file
View File

@@ -0,0 +1,59 @@
# CLAUDE.md — Contexto para Claude Code
## Qué es ABE
ABE (Autonomous Bug Explorer) es una plataforma enterprise self-hosted de
descubrimiento autónomo de bugs en aplicaciones web.
## Estado actual
Fases 1-11 originales implementadas. Ahora refactorizando hacia arquitectura
modular hexagonal enterprise. Ver `.ralph/PROMPT.md` para detalles completos.
## Arquitectura
Modular monolith hexagonal con bounded contexts.
**Regla #1**: Domain NUNCA importa infrastructure.
**Regla #2**: Cross-module communication SOLO via EventBus.
**Regla #3**: Controllers son thin — delegan a use cases.
**Regla #4**: Use cases retornan Result<T, E>, nunca throw.
## Comandos principales
```bash
npm run build # build backend
cd frontend && npm run build # build frontend
npm run test # vitest tests
npm run lint # eslint
npm run db:migrate # kysely migrations
docker compose up -d --build # todo con Docker
```
## Verificación obligatoria después de cambios
```bash
npm run build && cd frontend && npm run build && cd .. && npm run test
```
## Commit después de cada tarea
```bash
git add -A && git commit -m "fase(X.Y): descripción"
```
## Stack
- Backend: Node 20, TypeScript strict, Express, socket.io, Kysely, better-sqlite3, Pino, Zod, Better Auth, CASL, Playwright
- Frontend: React 18, Vite, shadcn/ui, Tailwind, Tremor, Recharts, TanStack Query/Table, Zustand
## Estructura
```
src/shared/ → building blocks compartidos
src/modules/ → bounded contexts
src/api/ → Express server + middleware global
src/realtime/ → socket.io gateway
src/jobs/ → job queue SQLite-backed
src/cli/ → CLI
src/main.ts → composition root
frontend/ → React app
```
## Para desarrollo con Ralph
```bash
cat .ralph/fix_plan.md | grep -E "^\- \[" | head -30
ralph --monitor
```

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# ---- Production stage ----
FROM node:20-alpine
WORKDIR /app
# System dependencies required by Playwright / Chromium and healthcheck
RUN apk add --no-cache \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont \
curl
# Tell Playwright to use the system Chromium instead of downloading its own
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
# Runtime directories for reports and logs
RUN mkdir -p reports logs
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
CMD ["node", "dist/server/index.js"]

127
README.md
View File

@@ -1 +1,126 @@
# abe
# ABE — Autonomous Bug Explorer
An open-source framework that autonomously explores web applications, provokes failures, and generates reproducible bug reports for developers and AI coding assistants.
## Quick Start
```bash
# Install dependencies
npm install
# Install Playwright browser
npx playwright install chromium
# Run ABE against your app
npm run explore -- --url http://localhost:3000 --output ./reports
# Replay a discovered bug
npm run replay -- --report reports/<anomaly-id>/report.json
```
## What ABE Does
1. Launches a headless browser and navigates to the target URL
2. Discovers interactive elements (links, buttons, inputs)
3. Executes actions deterministically using a seed
4. Observes HTTP responses, JS exceptions, and console errors
5. Detects anomalies using heuristic rules
6. Captures screenshots and DOM snapshots at anomaly moments
7. Generates a JSON + Markdown bug report with exact reproduction steps
## Project Structure
```
src/
├── core/ # Interfaces, StateGraph, ExplorationEngine, AnomalyDetector
└── plugins/
├── agents/ # PlaywrightAgent
├── collectors/ # Screenshot, Network, DOMSnapshot
├── exporters/ # JSON, Markdown
└── reproducers/ # PlaywrightReproducer
tests/ # Unit and integration tests (mirrors src/)
reports/ # Generated bug reports (runtime)
logs/ # Session logs in .jsonl format (runtime)
```
## CLI Options
| Option | Default | Description |
|--------|---------|-------------|
| `--url` | `http://localhost:3000` | Target URL |
| `--output` | `./reports` | Output directory |
| `--seed` | `42` | Random seed for determinism |
| `--max-steps` | `100` | Maximum exploration steps |
## Web UI (API Server + Dashboard)
ABE also ships a web dashboard for launching explorations and watching results in real time.
```bash
# Start both the API server (port 3001) and the React frontend (port 5173)
npm run dev:all
```
Then open `http://localhost:5173` in your browser.
### API Server only
```bash
npm run server
```
REST endpoints available at `http://localhost:3001/api/`:
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/sessions` | Start a new exploration |
| `GET` | `/sessions` | List all sessions |
| `GET` | `/sessions/:id` | Session detail |
| `DELETE` | `/sessions/:id` | Stop a running session |
| `GET` | `/anomalies` | List all anomalies |
| `GET` | `/anomalies/:id` | Anomaly detail |
| `GET` | `/anomalies/:id/screenshot` | Bug screenshot (PNG) |
| `POST` | `/anomalies/:id/replay` | Trigger anomaly replay |
WebSocket events are emitted via socket.io (connect to `http://localhost:3001`).
## Docker
Run the full stack (backend + frontend) with a single command:
```bash
docker-compose up --build
```
| Service | Host port | Description |
|---------|-----------|-------------|
| Backend | `3001` | Express API + socket.io |
| Frontend | `5173` | React dashboard (nginx) |
Then open `http://localhost:5173` in your browser.
Reports and logs are persisted via Docker volumes (`./reports`, `./logs`).
## Development
```bash
npm run build # Compile TypeScript
npm test # Run all tests
npm run typecheck # Type-check without compiling
```
## Architecture
```
frontend/ (React + Vite, port 5173)
↕ HTTP REST + WebSocket
src/server/ (Express + socket.io, port 3001)
↕ imports
src/core/ + src/plugins/ (ABE engine)
```
Core principles:
- **Deterministic**: all random choices are seeded and logged
- **Plugin-oriented**: core engine never imports concrete plugin classes
- **Reproducible**: every anomaly includes an exact action trace and replay script

BIN
data/abe.db Normal file

Binary file not shown.

BIN
data/abe.db-shm Normal file

Binary file not shown.

BIN
data/abe.db-wal Normal file

Binary file not shown.

252
dist/cli.js vendored Normal file
View File

@@ -0,0 +1,252 @@
"use strict";
/**
* ABE CLI — command-line interface for running explorations.
* Usage: abe run --url http://localhost:3000
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const commander_1 = require("commander");
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
const ExplorationConfig_1 = require("./core/ExplorationConfig");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const program = new commander_1.Command();
program
.name('abe')
.description('Autonomous Bug Explorer — explore web apps and find bugs')
.version('0.1.0');
program
.command('run')
.description('Run an exploration session against a target URL')
.requiredOption('--url <url>', 'Target URL to explore')
.option('--seed <seed>', 'Deterministic seed', parseInt, 42)
.option('--max-states <n>', 'Max states to explore', parseInt, 50)
.option('--max-depth <n>', 'Max click depth', parseInt, 5)
.option('--allowed-domains <domains>', 'Comma-separated allowed domains')
.option('--excluded-paths <paths>', 'Comma-separated excluded paths')
.option('--action-delay <ms>', 'Delay between actions in ms', parseInt, 500)
.option('--session-timeout <ms>', 'Session timeout in ms', parseInt, 300000)
// Auth options
.option('--auth-type <type>', 'Auth type: cookies | headers | login_flow')
.option('--login-url <url>', 'Login page URL (for login_flow)')
.option('--username <user>', 'Username (for login_flow)')
.option('--password <pass>', 'Password (for login_flow)')
.option('--username-selector <sel>', 'Username field selector (for login_flow)')
.option('--password-selector <sel>', 'Password field selector (for login_flow)')
.option('--submit-selector <sel>', 'Submit button selector (for login_flow)')
// Output
.option('--output <format>', 'Output format: human | json | junit', 'human')
.option('--reports-dir <dir>', 'Output directory for reports', './reports')
// CI flags
.option('--fail-on-anomaly', 'Exit 1 if any anomaly found')
.option('--fail-on-severity <level>', 'Exit 1 if anomaly at or above severity found')
// Remote server
.option('--server <url>', 'Connect to remote ABE server instead of running inline')
.option('--api-key <key>', 'API key for remote server')
.action(async (opts) => {
const startMs = Date.now();
// Build auth config
let auth = null;
if (opts.authType === 'login_flow') {
auth = {
type: 'login_flow',
loginUrl: opts.loginUrl ?? '',
usernameSelector: opts.usernameSelector ?? 'input[type="email"]',
passwordSelector: opts.passwordSelector ?? 'input[type="password"]',
submitSelector: opts.submitSelector ?? 'button[type="submit"]',
username: opts.username ?? '',
password: opts.password ?? '',
};
}
else if (opts.authType === 'headers') {
auth = { type: 'headers', headers: {} };
}
else if (opts.authType === 'cookies') {
auth = { type: 'cookies', cookies: [] };
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
maxStates: opts.maxStates,
maxDepth: opts.maxDepth,
actionDelayMs: opts.actionDelay,
sessionTimeoutMs: opts.sessionTimeout,
allowedDomains: opts.allowedDomains
? opts.allowedDomains.split(',').map((d) => d.trim())
: [new URL(opts.url).hostname],
excludedPaths: opts.excludedPaths
? opts.excludedPaths.split(',').map((p) => p.trim())
: [],
auth,
};
// If remote server mode
if (opts.server) {
await runRemote(opts, config);
return;
}
const anomalies = [];
let exitCode = 0;
let explorationError;
try {
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: opts.seed, explorationConfig: config });
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
seed: opts.seed,
url: opts.url,
maxSteps: opts.maxStates,
outputDir: opts.reportsDir,
explorationConfig: config,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(opts.reportsDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(opts.reportsDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onAnomalyDetected: (_, anomaly) => {
anomalies.push(anomaly);
},
onSessionError: (_, error) => {
explorationError = error;
},
},
});
await engine.run();
}
catch (err) {
explorationError = err instanceof Error ? err.message : String(err);
exitCode = 2;
}
if (explorationError && exitCode === 0)
exitCode = 2;
// Determine exit code from flags
if (exitCode === 0 && opts.failOnAnomaly && anomalies.length > 0) {
exitCode = 1;
}
if (exitCode === 0 && opts.failOnSeverity) {
const severityRank = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityRank[opts.failOnSeverity] ?? 0;
const failing = anomalies.filter((a) => (severityRank[a.severity] ?? 0) >= threshold);
if (failing.length > 0)
exitCode = 1;
}
const durationMs = Date.now() - startMs;
const summary = {
url: opts.url,
duration_ms: durationMs,
anomalies: anomalies.map((a) => ({
id: a.id,
type: a.type,
severity: a.severity,
description: a.description,
report_path: path.join(opts.reportsDir, a.id, 'report.json'),
})),
exit_code: exitCode,
};
if (opts.output === 'json') {
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
}
else if (opts.output === 'junit') {
const xml = buildJunit(summary, opts.url);
const outPath = path.join(process.cwd(), 'abe-results.xml');
fs.writeFileSync(outPath, xml, 'utf8');
if (opts.output !== 'json') {
console.log(`JUnit results written to ${outPath}`);
}
}
else {
if (anomalies.length === 0) {
console.log(`✓ ABE finished. No anomalies found. (${durationMs}ms)`);
}
else {
console.log(`⚠ ABE finished. ${anomalies.length} anomaly(ies) found:`);
for (const a of anomalies) {
console.log(` [${a.severity.toUpperCase()}] ${a.type}: ${a.description}`);
}
}
}
process.exit(exitCode);
});
async function runRemote(opts, _config) {
const serverUrl = opts['server'];
const apiKey = opts['apiKey'];
const url = opts['url'];
const headers = { 'Content-Type': 'application/json' };
if (apiKey)
headers['x-abe-api-key'] = apiKey;
const res = await fetch(`${serverUrl}/api/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({ url }),
});
if (!res.ok) {
console.error(`Server error: ${res.status} ${await res.text()}`);
process.exit(2);
return;
}
const session = await res.json();
console.log(`Session started: ${session.sessionId}`);
process.exit(0);
}
function buildJunit(summary, url) {
const anomalyCount = summary.anomalies.length;
const cases = summary.anomalies
.map((a) => ` <testcase name="${escapeXml(a.description)}" classname="abe.anomaly.${escapeXml(a.type)}">\n` +
` <failure message="${escapeXml(a.description)}" type="${escapeXml(a.severity)}">${escapeXml(a.id)}</failure>\n` +
` </testcase>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="ABE Exploration: ${escapeXml(url)}" tests="${anomalyCount}" failures="${anomalyCount}">\n` +
cases + '\n' +
`</testsuite>\n`;
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
program.parse(process.argv);

137
dist/core/AnomalyDetector.js vendored Normal file
View File

@@ -0,0 +1,137 @@
"use strict";
/**
* AnomalyDetector — heuristic rules to detect anomalies from observations.
* Each rule is independent and testable in isolation.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyDetector = void 0;
let anomalyCounter = 0;
function makeId() {
anomalyCounter += 1;
return `anom_${Date.now()}_${anomalyCounter.toString().padStart(4, '0')}`;
}
class AnomalyDetector {
detect(observation, actionTrace) {
const anomalies = [];
const httpAnomaly = this.checkHttpErrors(observation, actionTrace);
if (httpAnomaly)
anomalies.push(httpAnomaly);
const jsAnomaly = this.checkJsExceptions(observation, actionTrace);
if (jsAnomaly)
anomalies.push(jsAnomaly);
const consoleAnomaly = this.checkConsoleErrors(observation, actionTrace);
if (consoleAnomaly)
anomalies.push(consoleAnomaly);
return anomalies;
}
/** Rule: HTTP 4xx or 5xx responses */
checkHttpErrors(observation, actionTrace) {
const errorResponses = observation.httpResponses.filter((r) => r.status >= 400);
if (errorResponses.length === 0)
return null;
const hasServerError = errorResponses.some((r) => r.status >= 500);
const severity = hasServerError ? 'high' : 'medium';
const statusCodes = errorResponses.map((r) => r.status).join(', ');
return this.buildAnomaly({
type: 'http_error',
severity,
observationId: observation.id,
actionTrace,
description: `HTTP error responses detected: ${statusCodes}`,
evidence: {
httpLog: errorResponses,
rawErrors: errorResponses.map((r) => `${r.method} ${r.url}${r.status} (${r.durationMs}ms)`),
},
});
}
/** Rule: uncaught JS exceptions */
checkJsExceptions(observation, actionTrace) {
if (observation.jsExceptions.length === 0)
return null;
return this.buildAnomaly({
type: 'js_exception',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Uncaught JS exception: ${observation.jsExceptions[0]}`,
evidence: {
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: console.error messages */
checkConsoleErrors(observation, actionTrace) {
if (observation.consoleErrors.length === 0)
return null;
return this.buildAnomaly({
type: 'console_error',
severity: 'low',
observationId: observation.id,
actionTrace,
description: `Console error detected: ${observation.consoleErrors[0]}`,
evidence: {
rawErrors: observation.consoleErrors,
},
});
}
/**
* Rule: server accepted clearly invalid/empty fuzz input (got 2xx).
* fuzzedValue is the value that was submitted; responseStatus is the HTTP response.
*/
checkValidationBypass(observation, actionTrace, fuzzedValue) {
const has2xx = observation.httpResponses.some((r) => r.status >= 200 && r.status < 300);
if (!has2xx)
return null;
return this.buildAnomaly({
type: 'validation_bypass',
severity: 'high',
observationId: observation.id,
actionTrace,
description: `Server accepted invalid input without error (value: ${JSON.stringify(fuzzedValue).substring(0, 50)})`,
evidence: { httpLog: observation.httpResponses, rawErrors: [`Fuzzed with: ${fuzzedValue}`] },
});
}
/** Rule: server returned 500 on a fuzzed input */
checkServerErrorOnFuzz(observation, actionTrace) {
const has5xx = observation.httpResponses.some((r) => r.status >= 500);
if (!has5xx)
return null;
return this.buildAnomaly({
type: 'server_error_on_fuzz',
severity: 'high',
observationId: observation.id,
actionTrace,
description: 'Server returned 5xx on fuzzed input',
evidence: {
httpLog: observation.httpResponses.filter((r) => r.status >= 500),
rawErrors: observation.jsExceptions,
},
});
}
/** Rule: fuzzed script tag appears in response body (XSS reflection) */
checkXssReflection(observation, actionTrace, domSnapshot) {
if (!domSnapshot.includes('<script>alert(1)</script>'))
return null;
return this.buildAnomaly({
type: 'xss_reflection',
severity: 'critical',
observationId: observation.id,
actionTrace,
description: 'XSS reflection detected: fuzzed script tag appeared in DOM',
evidence: { rawErrors: ['XSS payload reflected in DOM'] },
});
}
buildAnomaly(params) {
return {
id: makeId(),
type: params.type,
severity: params.severity,
observationId: params.observationId,
actionTrace: params.actionTrace,
description: params.description,
evidence: params.evidence,
timestamp: Date.now(),
};
}
}
exports.AnomalyDetector = AnomalyDetector;

53
dist/core/ExplorationConfig.js vendored Normal file
View File

@@ -0,0 +1,53 @@
"use strict";
/**
* ExplorationConfig — defines scope, auth, fuzzing, multi-browser, a11y,
* performance, visual regression, and network chaos settings for a session.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_EXPLORATION_CONFIG = exports.NETWORK_PROFILES = void 0;
exports.NETWORK_PROFILES = {
'fast-3g': { downloadKbps: 1500, uploadKbps: 750, latencyMs: 40, offline: false },
'slow-3g': { downloadKbps: 400, uploadKbps: 150, latencyMs: 400, offline: false },
'2g': { downloadKbps: 50, uploadKbps: 30, latencyMs: 800, offline: false },
'offline': { downloadKbps: 0, uploadKbps: 0, latencyMs: 0, offline: true },
'none': null,
};
exports.DEFAULT_EXPLORATION_CONFIG = {
allowedDomains: [],
maxStates: 50,
maxDepth: 5,
actionDelayMs: 500,
sessionTimeoutMs: 300000,
excludedPaths: [],
excludedSelectors: [],
auth: null,
fuzzingEnabled: true,
fuzzingIntensity: 'medium',
browsers: ['chromium'],
mobileDevice: 'none',
viewport: null,
accessibility: {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
},
performance: {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
},
visualRegression: {
enabled: false,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
},
networkChaos: {
enabled: false,
profile: 'none',
blockedEndpoints: [],
slowEndpoints: [],
},
};

197
dist/core/ExplorationEngine.js vendored Normal file
View File

@@ -0,0 +1,197 @@
"use strict";
/**
* ExplorationEngine — the core loop of ABE.
* Selects states, executes actions, records observations, and detects anomalies.
* Depends only on core interfaces — never imports concrete plugins.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ExplorationEngine = void 0;
const AnomalyDetector_1 = require("./AnomalyDetector");
const Logger_1 = require("./Logger");
class ExplorationEngine {
constructor(config) {
/** Accumulated action trace for the current session */
this.actionTrace = [];
/** Set to true to abort the running loop */
this.aborted = false;
this.graph = config.graph;
this.agent = config.agent;
this.detector = config.detector ?? new AnomalyDetector_1.AnomalyDetector();
this.collectors = config.collectors ?? [];
this.exporters = config.exporters ?? [];
this.reproducer = config.reproducer;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.seed = config.seed;
this.url = config.url;
this.maxSteps = config.maxSteps ?? 100;
this.outputDir = config.outputDir ?? './reports';
this.events = config.events ?? {};
this.sessionId = config.sessionId ?? `${Date.now()}_${config.seed}`;
this.explorationConfig = config.explorationConfig ?? {};
this.fuzzingPlugin = config.fuzzingPlugin;
this.stateHooks = config.stateHooks ?? [];
}
/** Signals the engine to stop after the current step completes. */
stop() {
this.aborted = true;
}
async run() {
const anomalies = [];
let stepsExecuted = 0;
let depth = 0;
const sessionTimeoutMs = this.explorationConfig.sessionTimeoutMs ?? 0;
const maxDepth = this.explorationConfig.maxDepth ?? Infinity;
const sessionStart = Date.now();
this.logger.log({
event: 'session_start',
timestamp: sessionStart,
seed: this.seed,
target: this.url,
});
this.events.onSessionStarted?.(this.sessionId, this.url);
const isTimedOut = () => sessionTimeoutMs > 0 && Date.now() - sessionStart >= sessionTimeoutMs;
try {
await this.agent.launch(this.url);
// Capture initial state
const initialState = await this.agent.captureState();
this.graph.addState(initialState);
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: initialState.id,
url: initialState.url,
title: initialState.title,
});
this.events.onStateDiscovered?.(this.sessionId, initialState.id, initialState.url, initialState.title);
while (stepsExecuted < this.maxSteps && !this.aborted && !isTimedOut() && depth <= maxDepth) {
const currentState = this.graph.getNextToExplore();
if (!currentState)
break;
// Mark state as being explored
this.graph.incrementVisit(currentState.id);
// Discover available actions in this state
const actions = await this.agent.discoverActions(currentState);
if (actions.length === 0)
continue;
// Select action deterministically using seed + step count
const actionIndex = (this.seed + stepsExecuted) % actions.length;
const action = actions[actionIndex];
this.logger.log({
event: 'action_executed',
timestamp: Date.now(),
actionId: action.id,
type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
});
this.events.onActionExecuted?.(this.sessionId, action.type, action.selector, Date.now());
// Execute action and capture observation
const observation = await this.agent.executeAction(action);
this.actionTrace.push(action);
// Record new state if discovered
if (!this.graph.hasState(observation.newStateId)) {
const newState = await this.agent.captureState();
this.graph.addState(newState);
depth += 1;
this.logger.log({
event: 'state_discovered',
timestamp: Date.now(),
stateId: newState.id,
url: newState.url,
title: newState.title,
});
this.events.onStateDiscovered?.(this.sessionId, newState.id, newState.url, newState.title);
// Run per-state hooks (visual regression, accessibility, performance)
for (const hook of this.stateHooks) {
const hookAnomalies = await hook(newState, this.agent, this.sessionId, [...this.actionTrace]).catch(() => []);
for (const anomaly of hookAnomalies) {
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
this.graph.recordTransition(currentState.id, action, observation.newStateId);
this.logger.log({
event: 'exploration_step',
timestamp: Date.now(),
stateId: currentState.id,
actionId: action.id,
});
// Detect anomalies
const detected = this.detector.detect(observation, [...this.actionTrace]);
for (const anomaly of detected) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.logger.log({
event: 'anomaly_detected',
timestamp: Date.now(),
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
});
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
const reportDir = `${this.outputDir}/${anomaly.id}`;
await exporter.export(anomaly, reportDir);
}
}
stepsExecuted += 1;
// Run fuzzing if enabled and plugin provided
if (this.fuzzingPlugin &&
this.explorationConfig.fuzzingEnabled !== false &&
currentState.domSnapshot) {
const fuzzActions = this.fuzzingPlugin.generateFuzzActions(currentState.domSnapshot, currentState);
for (const fuzzAction of fuzzActions) {
if (this.aborted || isTimedOut())
break;
const fuzzObs = await this.agent.executeAction(fuzzAction);
this.actionTrace.push(fuzzAction);
const fuzzAnomalies = this.detector.detect(fuzzObs, [...this.actionTrace]);
for (const anomaly of fuzzAnomalies) {
for (const collector of this.collectors) {
const evidence = await collector.collect(anomaly, this.agent);
Object.assign(anomaly.evidence, evidence);
}
anomalies.push(anomaly);
this.events.onAnomalyDetected?.(this.sessionId, anomaly);
for (const exporter of this.exporters) {
await exporter.export(anomaly, `${this.outputDir}/${anomaly.id}`);
}
}
}
}
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.events.onSessionError?.(this.sessionId, msg);
await this.agent.close().catch(() => undefined);
throw err;
}
await this.agent.close();
const statesVisited = this.graph.getAllStates().filter((s) => s.visitCount > 0).length;
this.logger.log({
event: 'session_end',
timestamp: Date.now(),
statesVisited,
anomaliesFound: anomalies.length,
});
this.events.onSessionCompleted?.(this.sessionId, statesVisited, anomalies.length);
return { statesVisited, anomaliesFound: anomalies.length, anomalies };
}
}
exports.ExplorationEngine = ExplorationEngine;

66
dist/core/Logger.js vendored Normal file
View File

@@ -0,0 +1,66 @@
"use strict";
/**
* Logger — writes structured JSON log events to a .jsonl file.
* One JSON object per line.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.NullLogger = exports.FileLogger = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileLogger {
constructor(logDir, sessionId) {
const logPath = path.join(logDir, `session_${sessionId}.jsonl`);
fs.mkdirSync(logDir, { recursive: true });
this.stream = fs.createWriteStream(logPath, { flags: 'a' });
}
log(event) {
this.stream.write(JSON.stringify(event) + '\n');
}
close() {
this.stream.end();
}
}
exports.FileLogger = FileLogger;
/** No-op logger for testing */
class NullLogger {
constructor() {
this.events = [];
}
log(event) {
this.events.push(event);
}
}
exports.NullLogger = NullLogger;

83
dist/core/StateGraph.js vendored Normal file
View File

@@ -0,0 +1,83 @@
"use strict";
/**
* StateGraph — manages known states and transitions between them.
* Uses BFS ordering by default for exploration scheduling.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.StateGraph = void 0;
class StateGraph {
constructor() {
this.states = new Map();
this.transitions = [];
/** Insertion order for BFS */
this.insertionOrder = [];
}
addState(state) {
if (!this.states.has(state.id)) {
this.states.set(state.id, state);
this.insertionOrder.push(state.id);
}
else {
// Update visit count on revisit
const existing = this.states.get(state.id);
this.states.set(state.id, { ...existing, visitCount: existing.visitCount + 1 });
}
}
hasState(stateId) {
return this.states.has(stateId);
}
getState(stateId) {
return this.states.get(stateId);
}
incrementVisit(stateId) {
const state = this.states.get(stateId);
if (state) {
this.states.set(stateId, { ...state, visitCount: state.visitCount + 1 });
}
}
recordTransition(fromId, action, toId) {
this.transitions.push({
fromId,
action,
toId,
timestamp: Date.now(),
});
}
/** Returns all states that have never been visited (visitCount === 0) */
getUnvisited() {
return this.insertionOrder
.map((id) => this.states.get(id))
.filter((s) => s.visitCount === 0);
}
/** BFS heuristic: returns the oldest unvisited state, or null if none */
getNextToExplore() {
const unvisited = this.getUnvisited();
return unvisited.length > 0 ? unvisited[0] : null;
}
getAllStates() {
return this.insertionOrder.map((id) => this.states.get(id));
}
getTransitions() {
return [...this.transitions];
}
toJSON() {
return {
stateCount: this.states.size,
transitionCount: this.transitions.length,
states: this.getAllStates().map((s) => ({
id: s.id,
url: s.url,
title: s.title,
visitCount: s.visitCount,
})),
transitions: this.transitions.map((t) => ({
fromId: t.fromId,
toId: t.toId,
actionId: t.action.id,
actionType: t.action.type,
timestamp: t.timestamp,
})),
};
}
}
exports.StateGraph = StateGraph;

6
dist/core/interfaces.js vendored Normal file
View File

@@ -0,0 +1,6 @@
"use strict";
/**
* ABE Core Interfaces
* Core data types only. Must NOT import from src/plugins/.
*/
Object.defineProperty(exports, "__esModule", { value: true });

76
dist/db/AnomalyRepository.js vendored Normal file
View File

@@ -0,0 +1,76 @@
"use strict";
/**
* AnomalyRepository — CRUD for anomalies table with filters.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AnomalyRepository = void 0;
function rowToAnomaly(row) {
return {
id: row.id,
sessionId: row.session_id,
type: row.type,
severity: row.severity,
description: row.description,
actionTrace: JSON.parse(row.action_trace_json),
evidence: {
...JSON.parse(row.evidence_json),
screenshotPath: row.screenshot_path ?? undefined,
domSnapshotPath: row.dom_snapshot_path ?? undefined,
},
observationId: '',
timestamp: row.detected_at,
};
}
class AnomalyRepository {
constructor(db) {
this.db = db;
}
create(anomaly, sessionId) {
this.db
.prepare(`INSERT INTO anomalies
(id, session_id, type, severity, description, action_trace_json, evidence_json, screenshot_path, dom_snapshot_path, detected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(anomaly.id, sessionId, anomaly.type, anomaly.severity, anomaly.description, JSON.stringify(anomaly.actionTrace), JSON.stringify({ httpLog: anomaly.evidence.httpLog, rawErrors: anomaly.evidence.rawErrors }), anomaly.evidence.screenshotPath ?? null, anomaly.evidence.domSnapshotPath ?? null, anomaly.timestamp);
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM anomalies WHERE id = ?')
.get(id);
return row ? rowToAnomaly(row) : undefined;
}
findAll(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.severity) {
conditions.push('severity = ?');
values.push(filters.severity);
}
if (filters?.type) {
conditions.push('type = ?');
values.push(filters.type);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const rows = this.db
.prepare(`SELECT * FROM anomalies ${where} ORDER BY detected_at DESC`)
.all(...values);
return rows.map(rowToAnomaly);
}
countBySeverity(severities) {
if (severities.length === 0)
return 0;
const placeholders = severities.map(() => '?').join(', ');
const result = this.db
.prepare(`SELECT COUNT(*) as cnt FROM anomalies WHERE severity IN (${placeholders})`)
.get(...severities);
return result.cnt;
}
count() {
const result = this.db.prepare('SELECT COUNT(*) as cnt FROM anomalies').get();
return result.cnt;
}
}
exports.AnomalyRepository = AnomalyRepository;

82
dist/db/ScheduleRepository.js vendored Normal file
View File

@@ -0,0 +1,82 @@
"use strict";
/**
* ScheduleRepository — CRUD for schedules table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScheduleRepository = void 0;
function rowToRecord(row) {
return {
id: row.id,
name: row.name,
url: row.url,
configJson: row.config_json,
cronExpression: row.cron_expression,
enabled: row.enabled === 1,
lastRunAt: row.last_run_at,
nextRunAt: row.next_run_at,
createdAt: row.created_at,
};
}
class ScheduleRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO schedules (id, name, url, config_json, cron_expression, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(params.id, params.name, params.url, params.configJson, params.cronExpression, params.enabled !== false ? 1 : 0, params.nextRunAt ?? null, Date.now());
}
findById(id) {
const row = this.db
.prepare('SELECT * FROM schedules WHERE id = ?')
.get(id);
return row ? rowToRecord(row) : undefined;
}
findAll(enabledOnly = false) {
const rows = enabledOnly
? this.db.prepare('SELECT * FROM schedules WHERE enabled = 1 ORDER BY created_at DESC').all()
: this.db.prepare('SELECT * FROM schedules ORDER BY created_at DESC').all();
return rows.map(rowToRecord);
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.name !== undefined) {
sets.push('name = ?');
values.push(fields.name);
}
if (fields.url !== undefined) {
sets.push('url = ?');
values.push(fields.url);
}
if (fields.configJson !== undefined) {
sets.push('config_json = ?');
values.push(fields.configJson);
}
if (fields.cronExpression !== undefined) {
sets.push('cron_expression = ?');
values.push(fields.cronExpression);
}
if (fields.enabled !== undefined) {
sets.push('enabled = ?');
values.push(fields.enabled ? 1 : 0);
}
if (fields.lastRunAt !== undefined) {
sets.push('last_run_at = ?');
values.push(fields.lastRunAt);
}
if (fields.nextRunAt !== undefined) {
sets.push('next_run_at = ?');
values.push(fields.nextRunAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
}
}
exports.ScheduleRepository = ScheduleRepository;

53
dist/db/SessionRepository.js vendored Normal file
View File

@@ -0,0 +1,53 @@
"use strict";
/**
* SessionRepository — CRUD for sessions table.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionRepository = void 0;
class SessionRepository {
constructor(db) {
this.db = db;
}
create(params) {
this.db
.prepare(`INSERT INTO sessions (id, url, status, seed, max_states, started_at, config_json)
VALUES (?, ?, 'running', ?, ?, ?, ?)`)
.run(params.id, params.url, params.seed, params.maxStates, params.startedAt, params.configJson ?? '{}');
}
findById(id) {
return this.db
.prepare('SELECT * FROM sessions WHERE id = ?')
.get(id);
}
findAll() {
return this.db.prepare('SELECT * FROM sessions ORDER BY started_at DESC').all();
}
update(id, fields) {
const sets = [];
const values = [];
if (fields.status !== undefined) {
sets.push('status = ?');
values.push(fields.status);
}
if (fields.statesVisited !== undefined) {
sets.push('states_visited = ?');
values.push(fields.statesVisited);
}
if (fields.anomaliesFound !== undefined) {
sets.push('anomalies_found = ?');
values.push(fields.anomaliesFound);
}
if (fields.finishedAt !== undefined) {
sets.push('finished_at = ?');
values.push(fields.finishedAt);
}
if (sets.length === 0)
return;
values.push(id);
this.db.prepare(`UPDATE sessions SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
delete(id) {
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
}
exports.SessionRepository = SessionRepository;

77
dist/db/VisualBaselineRepository.js vendored Normal file
View File

@@ -0,0 +1,77 @@
"use strict";
/**
* VisualBaselineRepository — CRUD for visual_baselines and visual_comparisons tables.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VisualBaselineRepository = void 0;
class VisualBaselineRepository {
constructor(db) {
this.db = db;
}
// ─── Baselines ────────────────────────────────────────────────────────────
createBaseline(params) {
this.db.prepare(`
INSERT OR REPLACE INTO visual_baselines (id, state_id, url, screenshot_path, approved_at, approved_by, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.stateId, params.url, params.screenshotPath, Date.now(), params.approvedBy ?? 'user', params.width, params.height);
}
findBaselineByStateId(stateId) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE state_id = ? ORDER BY approved_at DESC LIMIT 1')
.get(stateId);
}
findBaselineById(id) {
return this.db
.prepare('SELECT * FROM visual_baselines WHERE id = ?')
.get(id);
}
// ─── Comparisons ──────────────────────────────────────────────────────────
createComparison(params) {
this.db.prepare(`
INSERT INTO visual_comparisons
(id, session_id, state_id, baseline_id, current_screenshot_path, diff_screenshot_path, diff_pixels, diff_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(params.id, params.sessionId, params.stateId, params.baselineId ?? null, params.currentScreenshotPath, params.diffScreenshotPath ?? null, params.diffPixels ?? null, params.diffPercent ?? null, params.status, Date.now());
}
findComparisonById(id) {
return this.db
.prepare('SELECT * FROM visual_comparisons WHERE id = ?')
.get(id);
}
findComparisons(filters) {
const conditions = [];
const values = [];
if (filters?.sessionId) {
conditions.push('session_id = ?');
values.push(filters.sessionId);
}
if (filters?.status) {
conditions.push('status = ?');
values.push(filters.status);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return this.db
.prepare(`SELECT * FROM visual_comparisons ${where} ORDER BY created_at DESC`)
.all(...values);
}
updateComparisonStatus(id, status) {
this.db.prepare('UPDATE visual_comparisons SET status = ? WHERE id = ?').run(status, id);
}
promoteToBaseline(comparisonId) {
const comparison = this.findComparisonById(comparisonId);
if (!comparison)
return null;
const baselineId = `baseline_${Date.now()}`;
this.createBaseline({
id: baselineId,
stateId: comparison.state_id,
url: comparison.session_id,
screenshotPath: comparison.current_screenshot_path,
width: 1280,
height: 720,
});
this.updateComparisonStatus(comparisonId, 'passed');
return baselineId;
}
}
exports.VisualBaselineRepository = VisualBaselineRepository;

43
dist/db/connection.js vendored Normal file
View File

@@ -0,0 +1,43 @@
"use strict";
/**
* ABE Database Connection
* Singleton SQLite connection using better-sqlite3.
* Runs migrations on first access.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getDb = getDb;
exports.setDb = setDb;
exports.closeDb = closeDb;
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const migrations_1 = require("./migrations");
let _db = null;
function getDb() {
if (_db)
return _db;
const dbPath = process.env['ABE_DB_PATH'] ?? path_1.default.join(process.cwd(), 'data', 'abe.db');
const dir = path_1.default.dirname(dbPath);
if (!fs_1.default.existsSync(dir)) {
fs_1.default.mkdirSync(dir, { recursive: true });
}
_db = new better_sqlite3_1.default(dbPath);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
(0, migrations_1.runMigrations)(_db);
return _db;
}
/** For testing — inject a custom (in-memory) database instance. */
function setDb(db) {
_db = db;
}
/** Close and reset. Used in tests. */
function closeDb() {
if (_db) {
_db.close();
_db = null;
}
}

126
dist/db/migrations.js vendored Normal file
View File

@@ -0,0 +1,126 @@
"use strict";
/**
* ABE Database Migrations
* Creates all tables if they do not exist.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.runMigrations = runMigrations;
function runMigrations(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running',
seed INTEGER NOT NULL,
max_states INTEGER NOT NULL DEFAULT 50,
states_visited INTEGER NOT NULL DEFAULT 0,
anomalies_found INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
finished_at INTEGER,
config_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS states (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
url TEXT NOT NULL,
title TEXT NOT NULL,
dom_snapshot_path TEXT,
visit_count INTEGER NOT NULL DEFAULT 0,
discovered_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS actions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
state_id TEXT NOT NULL REFERENCES states(id),
type TEXT NOT NULL,
selector TEXT,
value TEXT,
url TEXT,
seed INTEGER NOT NULL,
executed_at INTEGER NOT NULL,
sequence_order INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS anomalies (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT NOT NULL,
action_trace_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
screenshot_path TEXT,
dom_snapshot_path TEXT,
detected_at INTEGER NOT NULL,
ai_enrichment_json TEXT,
ai_enriched_at INTEGER,
browser TEXT,
browser_version TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
anomaly_id TEXT NOT NULL REFERENCES anomalies(id),
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
sent_at INTEGER,
error TEXT
);
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
config_json TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at INTEGER,
next_run_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_baselines (
id TEXT PRIMARY KEY,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
screenshot_path TEXT NOT NULL,
approved_at INTEGER NOT NULL,
approved_by TEXT DEFAULT 'user',
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS visual_comparisons (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
baseline_id TEXT,
current_screenshot_path TEXT NOT NULL,
diff_screenshot_path TEXT,
diff_pixels INTEGER,
diff_percent REAL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS performance_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
state_id TEXT NOT NULL,
url TEXT NOT NULL,
ttfb INTEGER,
dom_content_loaded INTEGER,
load_complete INTEGER,
lcp INTEGER,
cls REAL,
fid INTEGER,
inp INTEGER,
total_requests INTEGER,
failed_requests INTEGER,
total_transfer_size INTEGER,
captured_at INTEGER NOT NULL
);
`);
}

86
dist/index.js vendored Normal file
View File

@@ -0,0 +1,86 @@
"use strict";
/**
* ABE — Autonomous Bug Explorer
* Entry point: wires all components together and starts exploration.
*
* Usage:
* npm run explore -- --url http://localhost:3000 --output ./reports
* ts-node src/index.ts http://localhost:3000
*/
Object.defineProperty(exports, "__esModule", { value: true });
const ExplorationEngine_1 = require("./core/ExplorationEngine");
const StateGraph_1 = require("./core/StateGraph");
const Logger_1 = require("./core/Logger");
const PlaywrightAgent_1 = require("./plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("./plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("./plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("./plugins/collectors/DOMSnapshotCollector");
const JSONExporter_1 = require("./plugins/exporters/JSONExporter");
const MarkdownExporter_1 = require("./plugins/exporters/MarkdownExporter");
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let url = 'http://localhost:3000';
let outputDir = './reports';
let seed = 42;
let maxSteps = 100;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1])
url = args[++i];
else if (args[i] === '--output' && args[i + 1])
outputDir = args[++i];
else if (args[i] === '--seed' && args[i + 1])
seed = parseInt(args[++i], 10);
else if (args[i] === '--max-steps' && args[i + 1])
maxSteps = parseInt(args[++i], 10);
else if (!args[i].startsWith('--'))
url = args[i];
}
return { url, outputDir, seed, maxSteps };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const { url, outputDir, seed, maxSteps } = parseArgs();
const sessionId = `${new Date().toISOString().replace(/[:.]/g, '-')}_seed${seed}`;
const logger = new Logger_1.FileLogger('./logs', sessionId);
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed, headless: true, logger });
const collectors = [
new ScreenshotCollector_1.ScreenshotCollector(outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(outputDir),
];
const exporters = [
new JSONExporter_1.JSONExporter(url),
new MarkdownExporter_1.MarkdownExporter(),
];
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const engine = new ExplorationEngine_1.ExplorationEngine({
graph,
agent,
collectors,
exporters,
reproducer,
logger,
seed,
url,
maxSteps,
outputDir,
});
console.log(`[ABE] Starting exploration of ${url} (seed=${seed}, maxSteps=${maxSteps})`);
try {
const result = await engine.run();
console.log(`[ABE] Exploration complete.`);
console.log(` States visited : ${result.statesVisited}`);
console.log(` Anomalies found: ${result.anomaliesFound}`);
if (result.anomaliesFound > 0) {
console.log(` Reports saved to: ${outputDir}/`);
}
}
catch (err) {
console.error('[ABE] Fatal error:', err);
process.exit(1);
}
}
main();

501
dist/plugins/agents/PlaywrightAgent.js vendored Normal file
View File

@@ -0,0 +1,501 @@
"use strict";
/**
* PlaywrightAgent — implements IInteractionAgent using Playwright.
* All random choices use a deterministic seed and are logged.
* Supports scope enforcement, auth injection, and action delay.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PlaywrightAgent = void 0;
const crypto = __importStar(require("crypto"));
const playwright_1 = require("playwright");
const Logger_1 = require("../../core/Logger");
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
/** Simple deterministic pseudo-random number generator (LCG) */
class SeededRandom {
constructor(seed) {
this.state = seed;
}
/** Returns a float in [0, 1) */
next() {
this.state = (this.state * 1664525 + 1013904223) & 0xffffffff;
return (this.state >>> 0) / 0x100000000;
}
/** Returns an integer in [0, max) */
nextInt(max) {
return Math.floor(this.next() * max);
}
}
function generateId() {
return crypto.randomUUID();
}
function domHash(url, domSnapshot) {
return crypto
.createHash('sha1')
.update(url + domSnapshot)
.digest('hex')
.substring(0, 16);
}
class PlaywrightAgent {
constructor(config = {}) {
/** Captured HTTP responses for the current action */
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
this.seed = config.seed ?? 42;
this.rng = new SeededRandom(this.seed);
this.headless = config.headless ?? true;
this.timeoutMs = config.timeoutMs ?? 30000;
this.logger = config.logger ?? new Logger_1.NullLogger();
this.explorationConfig = config.explorationConfig ?? {};
}
async launch(url) {
// Select browser type
const browserType = this.explorationConfig.browsers?.[0] ?? 'chromium';
const launcher = browserType === 'firefox' ? playwright_1.firefox : browserType === 'webkit' ? playwright_1.webkit : playwright_1.chromium;
this.browser = await launcher.launch({ headless: this.headless });
// Apply auth headers if configured
const auth = this.explorationConfig.auth;
let contextOptions = {};
// Mobile device emulation
const mobileDevice = this.explorationConfig.mobileDevice;
if (mobileDevice && mobileDevice !== 'none') {
const device = playwright_1.devices[mobileDevice];
if (device) {
contextOptions = { ...device, ...contextOptions };
}
}
// Custom viewport
if (this.explorationConfig.viewport) {
contextOptions.viewport = this.explorationConfig.viewport;
}
if (auth?.type === 'headers') {
contextOptions.extraHTTPHeaders = auth.headers;
}
this.context = await this.browser.newContext(contextOptions);
// Apply auth cookies if configured
if (auth?.type === 'cookies') {
await this.context.addCookies(auth.cookies);
}
this.page = await this.context.newPage();
this.setupListeners(this.page);
// Apply network chaos conditions
await this.applyNetworkChaos(this.page);
// Login flow auth
if (auth?.type === 'login_flow') {
await this.performLoginFlow(auth);
}
await this.page.goto(url, { timeout: this.timeoutMs });
}
async close() {
await this.browser?.close();
this.browser = undefined;
this.context = undefined;
this.page = undefined;
}
async captureState() {
const page = this.requirePage();
const url = page.url();
const title = await page.title();
const domSnapshot = await page.evaluate(() => document.body.outerHTML);
const stateId = domHash(url, domSnapshot);
return {
id: stateId,
url,
title,
timestamp: Date.now(),
domSnapshot,
visitCount: 0,
};
}
async discoverActions(state) {
const page = this.requirePage();
const actions = [];
const now = Date.now();
const currentUrl = page.url();
if (this.isExcludedPath(currentUrl)) {
return [];
}
// Discover clickable elements
const clickableSelectors = [
'a[href]',
'button',
'[role="button"]',
'input[type="submit"]',
'input[type="button"]',
];
for (const selector of clickableSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
// Check element against excluded selectors
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
// For links, check domain is allowed
if (selector === 'a[href]') {
const href = await el.getAttribute('href').catch(() => null);
if (href && this.isExternalLink(href, currentUrl))
continue;
}
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'click',
selector: elSel,
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
// Discover fillable inputs
const inputSelectors = [
'input[type="text"]',
'input[type="email"]',
'input[type="password"]',
'textarea',
];
for (const selector of inputSelectors) {
if (this.isExcludedSelector(selector))
continue;
const elements = await page.locator(selector).all();
for (const el of elements) {
const isVisible = await el.isVisible().catch(() => false);
if (!isVisible)
continue;
const elSel = await this.buildSelector(el, selector);
if (this.isExcludedSelector(elSel))
continue;
const actionSeed = this.rng.nextInt(0x7fffffff);
actions.push({
id: generateId(),
type: 'fill',
selector: elSel,
value: '',
timestamp: now,
seed: actionSeed,
stateId: state.id,
});
}
}
return actions;
}
async executeAction(action) {
const page = this.requirePage();
this.resetPending();
const observationId = generateId();
const actionDelayMs = this.explorationConfig.actionDelayMs ?? 0;
// Skip actions targeting excluded paths
if (action.url && this.isExcludedPath(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
// Enforce allowed domains for navigate actions
if (action.type === 'navigate' && action.url) {
if (!this.isAllowedUrl(action.url)) {
const state = await this.captureState();
return this.buildObservation(observationId, action.id, state.id);
}
}
try {
switch (action.type) {
case 'click':
await page.locator(action.selector).first().click({ timeout: this.timeoutMs });
break;
case 'fill':
await page.locator(action.selector).first().fill(action.value ?? '', { timeout: this.timeoutMs });
break;
case 'navigate':
await page.goto(action.url, { timeout: this.timeoutMs });
break;
case 'submit':
await page.locator(action.selector).first().dispatchEvent('submit');
break;
case 'select':
await page.locator(action.selector).first().selectOption(action.value ?? '');
break;
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.pendingJsExceptions.push(`Action ${action.type} failed: ${msg}`);
}
// Wait for async effects to settle + configured delay
await page.waitForTimeout(200 + actionDelayMs);
const newState = await this.captureState();
return this.buildObservation(observationId, action.id, newState.id);
}
// ─── Private helpers ──────────────────────────────────────────────────────
getPage() {
return this.requirePage();
}
requirePage() {
if (!this.page)
throw new Error('PlaywrightAgent: not launched. Call launch() first.');
return this.page;
}
resetPending() {
this.pendingResponses = [];
this.pendingConsoleErrors = [];
this.pendingJsExceptions = [];
}
buildObservation(observationId, actionId, newStateId) {
return {
id: observationId,
actionId,
newStateId,
httpResponses: [...this.pendingResponses],
consoleErrors: [...this.pendingConsoleErrors],
jsExceptions: [...this.pendingJsExceptions],
timestamp: Date.now(),
};
}
setupListeners(page) {
const requestTimestamps = new Map();
page.on('request', (req) => {
requestTimestamps.set(req.url(), Date.now());
});
page.on('response', (res) => {
const start = requestTimestamps.get(res.url()) ?? Date.now();
const durationMs = Date.now() - start;
this.pendingResponses.push({
url: res.url(),
status: res.status(),
method: res.request().method(),
durationMs,
});
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
this.pendingConsoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => {
this.pendingJsExceptions.push(err.message);
});
}
async buildSelector(el, fallback) {
try {
const id = await el.getAttribute('id').catch(() => null);
if (id)
return `#${id}`;
const name = await el.getAttribute('name').catch(() => null);
if (name)
return `[name="${name}"]`;
}
catch {
// ignore
}
return fallback;
}
isExcludedPath(urlOrPath) {
const excludedPaths = this.explorationConfig.excludedPaths ?? [];
if (excludedPaths.length === 0)
return false;
try {
const parsed = new URL(urlOrPath, 'http://placeholder');
return excludedPaths.some((p) => parsed.pathname.startsWith(p));
}
catch {
return false;
}
}
isExcludedSelector(selector) {
const excludedSelectors = this.explorationConfig.excludedSelectors ?? [];
return excludedSelectors.includes(selector);
}
isExternalLink(href, currentUrl) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return false;
try {
const base = new URL(currentUrl);
const target = new URL(href, base.origin);
return !allowedDomains.includes(target.hostname);
}
catch {
return false;
}
}
isAllowedUrl(url) {
const allowedDomains = this.explorationConfig.allowedDomains ?? [];
if (allowedDomains.length === 0)
return true;
try {
const parsed = new URL(url);
return allowedDomains.includes(parsed.hostname);
}
catch {
return false;
}
}
async performLoginFlow(auth) {
const page = this.requirePage();
await page.goto(auth.loginUrl, { timeout: this.timeoutMs });
await page.locator(auth.usernameSelector).first().fill(auth.username);
await page.locator(auth.passwordSelector).first().fill(auth.password);
await page.locator(auth.submitSelector).first().click();
await page.waitForNavigation({ timeout: this.timeoutMs }).catch(() => undefined);
const currentUrl = page.url();
if (currentUrl === auth.loginUrl || currentUrl.includes(new URL(auth.loginUrl).pathname)) {
throw new Error(`Login failed: still on login page ${currentUrl}`);
}
}
async applyNetworkChaos(page) {
const chaos = this.explorationConfig.networkChaos;
if (!chaos?.enabled)
return;
// Apply network condition via CDP (Chromium only)
const profile = chaos.profile ?? 'none';
const condition = ExplorationConfig_1.NETWORK_PROFILES[profile];
if (condition) {
try {
const client = await this.context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: condition.offline,
downloadThroughput: condition.offline ? -1 : (condition.downloadKbps * 1024) / 8,
uploadThroughput: condition.offline ? -1 : (condition.uploadKbps * 1024) / 8,
latency: condition.latencyMs,
});
}
catch {
// CDP not available (e.g. non-Chromium browser) — ignore
}
}
// Block specified endpoints
if (chaos.blockedEndpoints && chaos.blockedEndpoints.length > 0) {
await page.route('**/*', (route) => {
const url = route.request().url();
const isBlocked = chaos.blockedEndpoints.some((pattern) => this.matchGlob(url, pattern));
if (isBlocked) {
route.fulfill({ status: 503, body: 'Service Unavailable (ABE Network Chaos)' });
}
else {
route.continue();
}
});
}
// Slow down specified endpoints
if (chaos.slowEndpoints && chaos.slowEndpoints.length > 0) {
for (const slowEp of chaos.slowEndpoints) {
await page.route(slowEp.pattern, async (route) => {
await new Promise((r) => setTimeout(r, slowEp.delayMs));
route.continue();
});
}
}
}
matchGlob(url, pattern) {
// Convert glob pattern to regex
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
try {
return new RegExp(escaped).test(url);
}
catch {
return false;
}
}
/**
* Detects mobile layout issues on the current page:
* - Touch targets smaller than 44x44px (WCAG 2.5.5 / Apple HIG)
* - Horizontal content overflow beyond viewport width
*/
async detectMobileLayoutIssues(stateId, sessionId, actionTrace) {
const page = this.requirePage();
const anomalies = [];
try {
const issues = await page.evaluate(() => {
const findings = [];
// Check for horizontal overflow
const docWidth = document.documentElement.scrollWidth;
const viewportWidth = window.innerWidth;
if (docWidth > viewportWidth) {
findings.push(`horizontal_overflow: ${docWidth}px > ${viewportWidth}px viewport`);
}
// Check for small touch targets (< 44x44 px)
const interactiveSelectors = ['a', 'button', '[role="button"]', 'input', 'select', 'textarea'];
const seen = new Set();
for (const sel of interactiveSelectors) {
for (const el of document.querySelectorAll(sel)) {
if (seen.has(el))
continue;
seen.add(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0)
continue;
if (rect.width < 44 || rect.height < 44) {
const label = el.getAttribute('aria-label') ||
el.textContent?.trim().slice(0, 30) ||
el.tagName.toLowerCase();
findings.push(`small_touch_target: "${label}" (${Math.round(rect.width)}x${Math.round(rect.height)}px)`);
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
}
if (findings.filter((f) => f.startsWith('small_touch_target')).length >= 5)
break;
}
return findings;
}).catch(() => []);
if (issues.length === 0)
return anomalies;
const hasOverflow = issues.some((i) => i.startsWith('horizontal_overflow'));
const smallTargetCount = issues.filter((i) => i.startsWith('small_touch_target')).length;
const severity = hasOverflow ? 'high' : smallTargetCount >= 3 ? 'medium' : 'low';
anomalies.push({
id: generateId(),
type: 'mobile_layout_issue',
severity,
observationId: stateId,
actionTrace,
description: `Mobile layout issues detected: ${issues.slice(0, 3).join('; ')}`,
evidence: { rawErrors: issues },
timestamp: Date.now(),
});
}
catch {
// Page may be in invalid state — ignore
}
return anomalies;
}
}
exports.PlaywrightAgent = PlaywrightAgent;

View File

@@ -0,0 +1,124 @@
"use strict";
/**
* AccessibilityCollector — runs axe-core after state changes to detect WCAG violations.
* Converts axe violations to IAnomaly with severity mapped from impact level.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccessibilityCollector = exports.DEFAULT_A11Y_CONFIG = void 0;
const crypto = __importStar(require("crypto"));
exports.DEFAULT_A11Y_CONFIG = {
enabled: true,
minImpact: 'serious',
wcagLevel: 'AA',
};
const IMPACT_TO_SEVERITY = {
minor: 'low',
moderate: 'medium',
serious: 'high',
critical: 'critical',
};
const IMPACT_RANK = {
minor: 0,
moderate: 1,
serious: 2,
critical: 3,
};
class AccessibilityCollector {
constructor(config = {}) {
this.config = { ...exports.DEFAULT_A11Y_CONFIG, ...config };
}
async collect(page, stateId, sessionId, actionTrace) {
if (!this.config.enabled)
return [];
try {
const violations = await this.runAxe(page);
const minRank = IMPACT_RANK[this.config.minImpact] ?? 2;
const anomalies = [];
for (const violation of violations) {
const impact = violation.impact ?? 'minor';
if ((IMPACT_RANK[impact] ?? 0) < minRank)
continue;
const severity = IMPACT_TO_SEVERITY[impact] ?? 'medium';
anomalies.push({
id: crypto.randomUUID(),
type: 'accessibility_violation',
severity,
observationId: stateId,
actionTrace,
description: `[axe] ${violation.description}`,
evidence: {
rawErrors: [
`Rule: ${violation.id}`,
`Impact: ${impact}`,
`Affected nodes: ${violation.nodes.length}`,
`Help: ${violation.helpUrl}`,
],
},
timestamp: Date.now(),
});
}
return anomalies;
}
catch {
// axe might fail if page is not in a valid state
return [];
}
}
async runAxe(page) {
try {
const { AxeBuilder } = await Promise.resolve().then(() => __importStar(require('@axe-core/playwright')));
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
return results.violations;
}
catch {
// Fallback: try via page.evaluate if AxeBuilder fails
const results = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window;
if (typeof win.axe === 'undefined')
return [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const r = await win.axe.run();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return r.violations;
}).catch(() => []);
return Array.isArray(results) ? results : [];
}
}
}
exports.AccessibilityCollector = AccessibilityCollector;

View File

@@ -0,0 +1,56 @@
"use strict";
/**
* DOMSnapshotCollector — writes the DOM snapshot at anomaly moment to disk.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.DOMSnapshotCollector = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class DOMSnapshotCollector {
constructor(outputDir = './reports') {
this.outputDir = outputDir;
this.name = 'DOMSnapshotCollector';
}
async collect(anomaly, agent) {
const state = await agent.captureState();
const dir = path.join(this.outputDir, anomaly.id);
fs.mkdirSync(dir, { recursive: true });
const domPath = path.join(dir, 'dom.html');
fs.writeFileSync(domPath, state.domSnapshot, 'utf8');
return { domSnapshotPath: path.relative(this.outputDir, domPath) };
}
}
exports.DOMSnapshotCollector = DOMSnapshotCollector;

View File

@@ -0,0 +1,18 @@
"use strict";
/**
* NetworkCollector — logs all HTTP responses from the current observation.
* The data is already captured in the observation; this collector formats it.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.NetworkCollector = void 0;
class NetworkCollector {
constructor() {
this.name = 'NetworkCollector';
}
async collect(anomaly, _agent) {
// HTTP responses are captured in the observation → anomaly evidence
const httpLog = anomaly.evidence.httpLog ?? [];
return { httpLog };
}
}
exports.NetworkCollector = NetworkCollector;

View File

@@ -0,0 +1,177 @@
"use strict";
/**
* PerformanceCollector — captures Navigation Timing and Core Web Vitals after each navigation.
* Detects performance_degradation anomalies based on configurable thresholds.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PerformanceCollector = exports.DEFAULT_PERF_CONFIG = void 0;
const crypto = __importStar(require("crypto"));
exports.DEFAULT_PERF_CONFIG = {
enabled: true,
lcpThresholdMs: 4000,
clsThreshold: 0.25,
inpThresholdMs: 500,
ttfbThresholdMs: 1800,
};
class PerformanceCollector {
constructor(config = {}) {
this.metricsStore = [];
this.config = { ...exports.DEFAULT_PERF_CONFIG, ...config };
}
async collect(page, stateId, sessionId, actionTrace) {
if (!this.config.enabled) {
const empty = {
id: crypto.randomUUID(), sessionId, stateId, url: page.url(),
ttfb: 0, domContentLoaded: 0, loadComplete: 0,
lcp: null, cls: null, fid: null, inp: null,
totalRequests: 0, failedRequests: 0, capturedAt: Date.now(),
};
return { metrics: empty, anomalies: [] };
}
// Capture Navigation Timing
const timing = await page.evaluate(() => {
const t = performance.timing;
return {
ttfb: t.responseStart - t.requestStart,
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
loadComplete: t.loadEventEnd - t.navigationStart,
};
}).catch(() => ({ ttfb: 0, domContentLoaded: 0, loadComplete: 0 }));
// Capture Core Web Vitals via PerformanceObserver
const vitals = await page.evaluate(() => {
return new Promise((resolve) => {
const result = {
lcp: null, cls: null, inp: null,
};
try {
// Try to observe LCP
if ('PerformanceObserver' in window) {
try {
const lcpObs = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
result.lcp = entries[entries.length - 1].startTime;
}
});
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
}
catch { /* not supported */ }
try {
const clsObs = new PerformanceObserver((list) => {
let clsScore = 0;
for (const entry of list.getEntries()) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
clsScore += entry.value ?? 0;
}
result.cls = clsScore;
});
clsObs.observe({ type: 'layout-shift', buffered: true });
}
catch { /* not supported */ }
}
}
catch { /* ignore */ }
// Resolve after short wait
setTimeout(() => resolve(result), 500);
});
}).catch(() => ({ lcp: null, cls: null, inp: null }));
const metrics = {
id: crypto.randomUUID(),
sessionId,
stateId,
url: page.url(),
ttfb: timing.ttfb,
domContentLoaded: timing.domContentLoaded,
loadComplete: timing.loadComplete,
lcp: vitals.lcp,
cls: vitals.cls,
fid: null,
inp: vitals.inp,
totalRequests: 0,
failedRequests: 0,
capturedAt: Date.now(),
};
this.metricsStore.push(metrics);
const anomalies = this.detectAnomalies(metrics, stateId, actionTrace);
return { metrics, anomalies };
}
getMetrics() {
return this.metricsStore;
}
detectAnomalies(metrics, stateId, actionTrace) {
const anomalies = [];
const issues = [];
let severityRank = 0; // 0=low,1=medium,2=high,3=critical
if (metrics.lcp !== null && metrics.lcp > this.config.lcpThresholdMs) {
issues.push(`LCP: ${metrics.lcp}ms (threshold: ${this.config.lcpThresholdMs}ms)`);
if (severityRank < 2)
severityRank = 2; // high
}
if (metrics.cls !== null && metrics.cls > this.config.clsThreshold) {
issues.push(`CLS: ${metrics.cls.toFixed(3)} (threshold: ${this.config.clsThreshold})`);
if (severityRank < 1)
severityRank = 1; // medium
}
if (metrics.inp !== null && metrics.inp > this.config.inpThresholdMs) {
issues.push(`INP: ${metrics.inp}ms (threshold: ${this.config.inpThresholdMs}ms)`);
if (severityRank < 2)
severityRank = 2; // high
}
if (metrics.ttfb > this.config.ttfbThresholdMs) {
issues.push(`TTFB: ${metrics.ttfb}ms (threshold: ${this.config.ttfbThresholdMs}ms)`);
if (severityRank < 1)
severityRank = 1; // medium
}
const RANK_TO_SEVERITY = ['low', 'medium', 'high', 'critical'];
const maxSeverity = RANK_TO_SEVERITY[severityRank] ?? 'low';
if (issues.length === 0)
return anomalies;
anomalies.push({
id: crypto.randomUUID(),
type: 'performance_degradation',
severity: maxSeverity,
observationId: stateId,
actionTrace,
description: `Performance degradation at ${metrics.url}: ${issues[0]}`,
evidence: {
rawErrors: issues,
},
timestamp: Date.now(),
});
return anomalies;
}
}
exports.PerformanceCollector = PerformanceCollector;

View File

@@ -0,0 +1,63 @@
"use strict";
/**
* ScreenshotCollector — captures a PNG screenshot at anomaly moment.
* Requires the agent to be a PlaywrightAgent (duck-typing check).
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ScreenshotCollector = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
function isPlaywrightAgent(agent) {
return typeof agent.getPage === 'function';
}
class ScreenshotCollector {
constructor(outputDir = './reports') {
this.outputDir = outputDir;
this.name = 'ScreenshotCollector';
}
async collect(anomaly, agent) {
if (!isPlaywrightAgent(agent)) {
return {};
}
const page = agent.getPage();
const dir = path.join(this.outputDir, anomaly.id);
fs.mkdirSync(dir, { recursive: true });
const screenshotPath = path.join(dir, 'screenshot.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
return { screenshotPath: path.relative(this.outputDir, screenshotPath) };
}
}
exports.ScreenshotCollector = ScreenshotCollector;

View File

@@ -0,0 +1,155 @@
"use strict";
/**
* VisualRegressionCollector — captures screenshots and compares against baselines.
* Uses pixelmatch for pixel-level comparison and sharp for image normalization.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.VisualRegressionCollector = exports.DEFAULT_VISUAL_CONFIG = void 0;
exports.compareScreenshots = compareScreenshots;
const crypto = __importStar(require("crypto"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
exports.DEFAULT_VISUAL_CONFIG = {
enabled: true,
threshold: 0.001,
screenshotFullPage: false,
ignoreSelectors: [],
};
async function compareScreenshots(baselinePath, currentPath, diffOutputPath, threshold = 0.1) {
// Dynamic imports to avoid loading heavy deps at startup
const sharp = (await Promise.resolve().then(() => __importStar(require('sharp')))).default;
const pixelmatch = (await Promise.resolve().then(() => __importStar(require('pixelmatch')))).default;
const [baselineRaw, currentRaw] = await Promise.all([
sharp(baselinePath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
sharp(currentPath).resize(1280, 720).raw().toBuffer({ resolveWithObject: true }),
]);
const { width, height } = baselineRaw.info;
const diffBuffer = Buffer.alloc(width * height * 4);
const diffPixels = pixelmatch(baselineRaw.data, currentRaw.data, diffBuffer, width, height, { threshold });
const totalPixels = width * height;
const diffPercent = totalPixels > 0 ? diffPixels / totalPixels : 0;
// Write diff image
await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
.png()
.toFile(diffOutputPath);
return { diffPixels, diffPercent, hasDiff: diffPixels > 0 };
}
class VisualRegressionCollector {
constructor(outputDir, repo, config = {}) {
this.outputDir = outputDir;
this.repo = repo;
this.config = { ...exports.DEFAULT_VISUAL_CONFIG, ...config };
}
/**
* Process a screenshot for visual regression.
* Returns an anomaly if a regression is detected, otherwise null.
*/
async processScreenshot(screenshotPath, state, sessionId, actionTrace) {
if (!this.config.enabled)
return null;
const comparisonId = crypto.randomUUID();
const baseline = this.repo.findBaselineByStateId(state.id);
if (!baseline) {
// No baseline: create a new_state comparison record
this.repo.createComparison({
id: comparisonId,
sessionId,
stateId: state.id,
currentScreenshotPath: screenshotPath,
status: 'new_state',
});
return null;
}
// Compare against baseline
const diffDir = path.join(this.outputDir, 'visual', comparisonId);
if (!fs.existsSync(diffDir)) {
fs.mkdirSync(diffDir, { recursive: true });
}
const diffPath = path.join(diffDir, 'diff.png');
let diffPixels = 0;
let diffPercent = 0;
try {
const result = await compareScreenshots(baseline.screenshot_path, screenshotPath, diffPath, this.config.threshold);
diffPixels = result.diffPixels;
diffPercent = result.diffPercent;
}
catch {
// If comparison fails (e.g. image format issues), skip
return null;
}
const thresholdPct = this.config.threshold;
const status = diffPercent > thresholdPct ? 'failed' : 'passed';
this.repo.createComparison({
id: comparisonId,
sessionId,
stateId: state.id,
baselineId: baseline.id,
currentScreenshotPath: screenshotPath,
diffScreenshotPath: status === 'failed' ? diffPath : undefined,
diffPixels,
diffPercent,
status,
});
if (status !== 'failed')
return null;
// Determine severity from diff percent
const pct = diffPercent * 100;
let severity;
if (pct > 15)
severity = 'critical';
else if (pct > 5)
severity = 'high';
else if (pct > 1)
severity = 'medium';
else
severity = 'low';
const anomaly = {
id: crypto.randomUUID(),
type: 'visual_regression',
severity,
observationId: state.id,
actionTrace,
description: `Visual regression detected: ${(pct).toFixed(2)}% of pixels changed`,
evidence: {
screenshotPath: diffPath,
rawErrors: [`Diff: ${diffPixels} pixels (${(pct).toFixed(2)}%)`],
},
timestamp: Date.now(),
};
return anomaly;
}
}
exports.VisualRegressionCollector = VisualRegressionCollector;

97
dist/plugins/exporters/JSONExporter.js vendored Normal file
View File

@@ -0,0 +1,97 @@
"use strict";
/**
* JSONExporter — produces a structured JSON report for AI debugging workflows.
* Output: reports/{anomaly-id}/report.json
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.JSONExporter = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
class JSONExporter {
constructor(targetUrl = '', abeVersion = '0.1.0') {
this.targetUrl = targetUrl;
this.abeVersion = abeVersion;
this.format = 'json';
}
async export(anomaly, outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const report = {
version: '1.0',
generated_at: new Date(anomaly.timestamp).toISOString(),
environment: {
target_url: this.targetUrl,
abe_version: this.abeVersion,
os: os.platform(),
node_version: process.version,
},
anomaly: {
id: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
description: anomaly.description,
timestamp: anomaly.timestamp,
},
reproduction: {
seed: anomaly.actionTrace[0]?.seed ?? null,
steps: anomaly.actionTrace.map((action, index) => ({
step: index + 1,
action_type: action.type,
selector: action.selector,
value: action.value,
url: action.url,
timestamp: action.timestamp,
})),
},
evidence: {
screenshot: anomaly.evidence.screenshotPath ?? null,
dom_snapshot: anomaly.evidence.domSnapshotPath ?? null,
http_log: (anomaly.evidence.httpLog ?? []).map((r) => ({
url: r.url,
method: r.method,
status: r.status,
duration_ms: r.durationMs,
})),
console_errors: anomaly.evidence.rawErrors?.filter((e) => e.startsWith('console:')) ?? [],
js_exceptions: anomaly.evidence.rawErrors?.filter((e) => !e.startsWith('console:')) ?? [],
},
};
const filePath = path.join(outputDir, 'report.json');
fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf8');
return filePath;
}
}
exports.JSONExporter = JSONExporter;

View File

@@ -0,0 +1,113 @@
"use strict";
/**
* MarkdownExporter — produces a human-readable bug report.
* Output: reports/{anomaly-id}/report.md
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.MarkdownExporter = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class MarkdownExporter {
constructor() {
this.format = 'markdown';
}
async export(anomaly, outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
const date = new Date(anomaly.timestamp).toISOString().split('T')[0];
const seed = anomaly.actionTrace[0]?.seed ?? 'N/A';
const replayCmd = `npm run replay -- --report ${outputDir}/report.json`;
const steps = anomaly.actionTrace
.map((action, i) => {
switch (action.type) {
case 'navigate':
return `${i + 1}. Navigate to \`${action.url}\``;
case 'click':
return `${i + 1}. Click element \`${action.selector}\``;
case 'fill':
return `${i + 1}. Fill \`${action.selector}\` with \`${JSON.stringify(action.value ?? '')}\``;
case 'select':
return `${i + 1}. Select \`${action.value}\` in \`${action.selector}\``;
case 'submit':
return `${i + 1}. Submit form \`${action.selector}\``;
default:
return `${i + 1}. ${action.type}`;
}
})
.join('\n');
const httpTable = (anomaly.evidence.httpLog ?? []).length > 0
? [
'| Method | URL | Status | Duration |',
'|--------|-----|--------|----------|',
...(anomaly.evidence.httpLog ?? []).map((r) => `| ${r.method} | ${r.url} | ${r.status} | ${r.durationMs}ms |`),
].join('\n')
: '_No HTTP log available._';
const rawErrors = (anomaly.evidence.rawErrors ?? []).length > 0
? '```\n' + anomaly.evidence.rawErrors.join('\n') + '\n```'
: '_No raw errors recorded._';
const md = `# Bug Report — ${anomaly.type}${date}
## Summary
${anomaly.description}
## Severity
**${anomaly.severity}** — detected by ABE heuristic rule \`${anomaly.type}\`
## Reproduction Steps
${steps.length > 0 ? steps : '_No steps recorded._'}
**Seed used**: \`${seed}\`
**Replay command**: \`${replayCmd}\`
## Observed Behavior
${anomaly.description}
## Evidence
- Screenshot: \`${anomaly.evidence.screenshotPath ?? 'N/A'}\`
- DOM Snapshot: \`${anomaly.evidence.domSnapshotPath ?? 'N/A'}\`
- HTTP Log:
${httpTable}
## Raw Errors
${rawErrors}
`;
const filePath = path.join(outputDir, 'report.md');
fs.writeFileSync(filePath, md, 'utf8');
return filePath;
}
}
exports.MarkdownExporter = MarkdownExporter;

139
dist/plugins/fuzzers/FuzzingEngine.js vendored Normal file
View File

@@ -0,0 +1,139 @@
"use strict";
/**
* FuzzingEngine — orchestrates fuzzing strategies for form inputs.
* Implements IFuzzingPlugin so ExplorationEngine doesn't need to import it directly.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.FuzzingEngine = void 0;
const crypto = __importStar(require("crypto"));
const InputTypeDetector_1 = require("./InputTypeDetector");
const EmptyValueStrategy_1 = require("./strategies/EmptyValueStrategy");
const OversizedStringStrategy_1 = require("./strategies/OversizedStringStrategy");
const SpecialCharsStrategy_1 = require("./strategies/SpecialCharsStrategy");
const TypeMismatchStrategy_1 = require("./strategies/TypeMismatchStrategy");
const BoundaryValueStrategy_1 = require("./strategies/BoundaryValueStrategy");
/** Regex to match basic input elements in an HTML string */
const INPUT_RE = /<(input|textarea|select)[^>]*>/gi;
const ATTR_RE = (name) => new RegExp(`${name}="([^"]*)"`, 'i');
function extractFields(domSnapshot) {
const fields = [];
let match;
while ((match = INPUT_RE.exec(domSnapshot)) !== null) {
const tag = match[0] ?? '';
const tagName = match[1] ?? 'input';
const idMatch = ATTR_RE('id').exec(tag);
const nameMatch = ATTR_RE('name').exec(tag);
const typeMatch = ATTR_RE('type').exec(tag);
const placeholderMatch = ATTR_RE('placeholder').exec(tag);
const ariaMatch = ATTR_RE('aria-label').exec(tag);
const selector = idMatch?.[1]
? `#${idMatch[1]}`
: nameMatch?.[1]
? `[name="${nameMatch[1]}"]`
: tagName;
fields.push({
selector,
tagName,
inputType: typeMatch?.[1],
name: nameMatch?.[1],
placeholder: placeholderMatch?.[1],
ariaLabel: ariaMatch?.[1],
});
}
return fields;
}
class FuzzingEngine {
constructor(config) {
this.intensity = config.intensity;
this.seed = config.seed;
}
/** IFuzzingPlugin implementation — parses fields from DOM snapshot */
generateFuzzActions(domSnapshot, state) {
const fields = extractFields(domSnapshot);
return this.generateFuzzActionsForFields(fields, state);
}
/** Generate fuzz actions from explicit field descriptors */
generateFuzzActionsForFields(fields, state) {
const actions = [];
const now = Date.now();
const strategies = this.selectStrategies();
for (const field of fields) {
const detectedType = (0, InputTypeDetector_1.detectInputType)({
tagName: field.tagName,
inputType: field.inputType,
name: field.name,
placeholder: field.placeholder,
ariaLabel: field.ariaLabel,
});
for (const strategy of strategies) {
const values = this.getValuesFromStrategy(strategy, detectedType);
for (const value of values) {
actions.push({
id: crypto.randomUUID(),
type: 'fill',
selector: field.selector,
value,
timestamp: now,
seed: this.seed,
stateId: state.id,
});
}
}
}
return actions;
}
selectStrategies() {
const empty = new EmptyValueStrategy_1.EmptyValueStrategy();
const typeMismatch = new TypeMismatchStrategy_1.TypeMismatchStrategy();
const oversized = new OversizedStringStrategy_1.OversizedStringStrategy(this.intensity);
const boundary = new BoundaryValueStrategy_1.BoundaryValueStrategy();
const special = new SpecialCharsStrategy_1.SpecialCharsStrategy();
switch (this.intensity) {
case 'low':
return [empty, typeMismatch];
case 'medium':
return [empty, typeMismatch, oversized, boundary];
case 'high':
return [empty, typeMismatch, oversized, boundary, special];
}
}
getValuesFromStrategy(strategy, type) {
if (!strategy.appliesTo(type))
return [];
return strategy.values(type);
}
}
exports.FuzzingEngine = FuzzingEngine;

View File

@@ -0,0 +1,52 @@
"use strict";
/**
* InputTypeDetector — detects field type from DOM attributes.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectInputType = detectInputType;
/** Detect type from input[type], name, placeholder, aria-label */
function detectInputType(attrs) {
const tag = (attrs.tagName ?? '').toLowerCase();
if (tag === 'textarea')
return 'textarea';
if (tag === 'select')
return 'select';
const inputType = (attrs.inputType ?? '').toLowerCase();
if (inputType === 'email')
return 'email';
if (inputType === 'password')
return 'password';
if (inputType === 'number')
return 'number';
if (inputType === 'date')
return 'date';
if (inputType === 'tel')
return 'phone';
if (inputType === 'url')
return 'url';
if (inputType === 'search')
return 'search';
if (inputType === 'file')
return 'file';
// Infer from name/placeholder/aria-label
const hints = [
(attrs.name ?? '').toLowerCase(),
(attrs.placeholder ?? '').toLowerCase(),
(attrs.ariaLabel ?? '').toLowerCase(),
].join(' ');
if (/email/.test(hints))
return 'email';
if (/password|pass/.test(hints))
return 'password';
if (/phone|tel|mobile/.test(hints))
return 'phone';
if (/date|birth|dob/.test(hints))
return 'date';
if (/number|qty|quantity|age/.test(hints))
return 'number';
if (/search/.test(hints))
return 'search';
if (/url|website|link/.test(hints))
return 'url';
return 'text';
}

View File

@@ -0,0 +1,26 @@
"use strict";
/**
* BoundaryValueStrategy — tests values at the edges of expected ranges.
* Applies to: number, date.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.BoundaryValueStrategy = void 0;
class BoundaryValueStrategy {
constructor() {
this.name = 'BoundaryValueStrategy';
}
appliesTo(type) {
return type === 'number' || type === 'date';
}
values(type) {
switch (type) {
case 'number':
return ['0', '-1', '2147483647', '2147483648', '-2147483648'];
case 'date':
return ['1900-01-01', '2099-12-31', '1970-01-01'];
default:
return [];
}
}
}
exports.BoundaryValueStrategy = BoundaryValueStrategy;

View File

@@ -0,0 +1,19 @@
"use strict";
/**
* EmptyValueStrategy — submits empty/whitespace values to catch missing server-side validation.
* Applies to: all input types.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmptyValueStrategy = void 0;
class EmptyValueStrategy {
constructor() {
this.name = 'EmptyValueStrategy';
}
appliesTo(_type) {
return true;
}
values() {
return ['', ' ', '\t'];
}
}
exports.EmptyValueStrategy = EmptyValueStrategy;

View File

@@ -0,0 +1,28 @@
"use strict";
/**
* OversizedStringStrategy — submits strings far beyond expected length.
* Applies to: text, email, password, textarea.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OversizedStringStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'password', 'textarea'];
class OversizedStringStrategy {
constructor(intensity) {
this.intensity = intensity;
this.name = 'OversizedStringStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
switch (this.intensity) {
case 'low':
return ['A'.repeat(256)];
case 'medium':
return ['A'.repeat(1024)];
case 'high':
return ['A'.repeat(10000) + '日本語テスト𠮷野家'];
}
}
}
exports.OversizedStringStrategy = OversizedStringStrategy;

View File

@@ -0,0 +1,26 @@
"use strict";
/**
* SpecialCharsStrategy — injects characters that break SQL, HTML, and shell contexts.
* Applies to: text, email, search, textarea.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SpecialCharsStrategy = void 0;
const APPLICABLE_TYPES = ['text', 'email', 'search', 'textarea'];
class SpecialCharsStrategy {
constructor() {
this.name = 'SpecialCharsStrategy';
}
appliesTo(type) {
return APPLICABLE_TYPES.includes(type);
}
values() {
return [
"' OR 1=1 --",
'<script>alert(1)</script>',
'../../etc/passwd',
'${7*7}',
'\x00\x01\x02',
];
}
}
exports.SpecialCharsStrategy = SpecialCharsStrategy;

View File

@@ -0,0 +1,31 @@
"use strict";
/**
* TypeMismatchStrategy — submits wrong data types for the detected field type.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TypeMismatchStrategy = void 0;
class TypeMismatchStrategy {
constructor() {
this.name = 'TypeMismatchStrategy';
}
appliesTo(type) {
return ['email', 'number', 'date', 'url', 'phone'].includes(type);
}
values(type) {
switch (type) {
case 'email':
return ['not-an-email', '12345', '@@@'];
case 'number':
return ['abc', '-999999', '9.9.9', 'NaN'];
case 'date':
return ['yesterday', '32/13/2025', '0000-00-00'];
case 'url':
return ['javascript:alert(1)', 'not a url'];
case 'phone':
return ['000', '++++', 'abcdefghij'];
default:
return [];
}
}
}
exports.TypeMismatchStrategy = TypeMismatchStrategy;

7
dist/plugins/interfaces.js vendored Normal file
View File

@@ -0,0 +1,7 @@
"use strict";
/**
* Plugin interfaces re-exported from core for plugin implementations to use.
* All interface definitions live in src/core/interfaces.ts so that core
* code can depend on them without creating a circular dependency.
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,59 @@
"use strict";
/**
* PlaywrightReproducer — serializes an action trace and generates a
* deterministic Playwright script for replay.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.PlaywrightReproducer = void 0;
class PlaywrightReproducer {
serialize(trace) {
return JSON.stringify(trace, null, 2);
}
deserialize(raw) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('PlaywrightReproducer.deserialize: expected a JSON array');
}
return parsed;
}
generateScript(trace) {
const lines = [
'// Auto-generated replay script by ABE (Autonomous Bug Explorer)',
`// Generated at: ${new Date().toISOString()}`,
`// Steps: ${trace.length}`,
'',
"const { chromium } = require('playwright');",
'',
'(async () => {',
' const browser = await chromium.launch({ headless: true });',
' const context = await browser.newContext();',
' const page = await context.newPage();',
'',
];
for (let i = 0; i < trace.length; i++) {
const action = trace[i];
lines.push(` // Step ${i + 1}: ${action.type} (seed=${action.seed})`);
switch (action.type) {
case 'navigate':
lines.push(` await page.goto(${JSON.stringify(action.url)});`);
break;
case 'click':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().click();`);
break;
case 'fill':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().fill(${JSON.stringify(action.value ?? '')});`);
break;
case 'select':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().selectOption(${JSON.stringify(action.value ?? '')});`);
break;
case 'submit':
lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().dispatchEvent('submit');`);
break;
}
lines.push('');
}
lines.push(" console.log('Replay complete');", ' await browser.close();', '})();');
return lines.join('\n');
}
}
exports.PlaywrightReproducer = PlaywrightReproducer;

84
dist/replay.js vendored Normal file
View File

@@ -0,0 +1,84 @@
"use strict";
/**
* ABE Replay Script Runner
* Loads a report.json and executes the generated Playwright replay script.
*
* Usage:
* npm run replay -- --report reports/anom_xyz/report.json
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const PlaywrightReproducer_1 = require("./plugins/reproducers/PlaywrightReproducer");
function parseArgs() {
const args = process.argv.slice(2);
let reportPath = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--report' && args[i + 1])
reportPath = args[++i];
}
if (!reportPath) {
console.error('Usage: npm run replay -- --report <path-to-report.json>');
process.exit(1);
}
return { reportPath };
}
async function main() {
const { reportPath } = parseArgs();
if (!fs.existsSync(reportPath)) {
console.error(`Report not found: ${reportPath}`);
process.exit(1);
}
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
// Reconstruct action trace from report steps
const trace = report.reproduction.steps.map((step) => ({
id: `replay_step_${step.step}`,
type: step.action_type,
selector: step.selector,
value: step.value,
url: step.url,
timestamp: step.timestamp,
seed: report.reproduction.seed ?? 42,
stateId: 'replay',
}));
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const script = reproducer.generateScript(trace);
const scriptPath = path.join(path.dirname(reportPath), 'replay.js');
fs.writeFileSync(scriptPath, script, 'utf8');
console.log(`[ABE Replay] Script written to: ${scriptPath}`);
console.log(`[ABE Replay] Run with: node ${scriptPath}`);
}
main();

398
dist/server/SessionStore.js vendored Normal file
View File

@@ -0,0 +1,398 @@
"use strict";
/**
* SessionStore — manages active sessions and persists to SQLite.
* In-memory map for running engines; DB for durable storage.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SessionStore = void 0;
const path_1 = __importDefault(require("path"));
const ExplorationEngine_1 = require("../core/ExplorationEngine");
const StateGraph_1 = require("../core/StateGraph");
const PlaywrightAgent_1 = require("../plugins/agents/PlaywrightAgent");
const ScreenshotCollector_1 = require("../plugins/collectors/ScreenshotCollector");
const NetworkCollector_1 = require("../plugins/collectors/NetworkCollector");
const DOMSnapshotCollector_1 = require("../plugins/collectors/DOMSnapshotCollector");
const MarkdownExporter_1 = require("../plugins/exporters/MarkdownExporter");
const JSONExporter_1 = require("../plugins/exporters/JSONExporter");
const PlaywrightReproducer_1 = require("../plugins/reproducers/PlaywrightReproducer");
const FuzzingEngine_1 = require("../plugins/fuzzers/FuzzingEngine");
const VisualRegressionCollector_1 = require("../plugins/collectors/VisualRegressionCollector");
const AccessibilityCollector_1 = require("../plugins/collectors/AccessibilityCollector");
const PerformanceCollector_1 = require("../plugins/collectors/PerformanceCollector");
class SessionStore {
constructor(outputDir = './reports', sessionRepo, anomalyRepo, maxConcurrentSessions = 3, notificationService, visualRepo) {
this.sessions = new Map();
this.emitter = () => undefined;
/** In-memory performance metrics keyed by sessionId */
this.performanceMetrics = new Map();
this.outputDir = outputDir;
this.sessionRepo = sessionRepo ?? null;
this.anomalyRepo = anomalyRepo ?? null;
this.maxConcurrentSessions = maxConcurrentSessions;
this.notificationService = notificationService ?? null;
this.visualRepo = visualRepo ?? null;
}
getPerformanceMetrics(sessionId) {
return this.performanceMetrics.get(sessionId) ?? [];
}
getMaxConcurrent() {
return this.maxConcurrentSessions;
}
setEmitter(emitter) {
this.emitter = emitter;
}
getAllSessions() {
if (this.sessionRepo) {
const rows = this.sessionRepo.findAll();
return rows.map((r) => {
const live = this.sessions.get(r.id);
return {
sessionId: r.id,
url: r.url,
seed: r.seed,
maxStates: r.max_states,
status: r.status,
startedAt: new Date(r.started_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
statesVisited: r.states_visited,
anomaliesFound: r.anomalies_found,
anomalies: live?.anomalies ?? [],
engine: live?.engine,
};
});
}
return Array.from(this.sessions.values());
}
getSession(sessionId) {
if (this.sessionRepo) {
const r = this.sessionRepo.findById(sessionId);
if (!r)
return undefined;
const live = this.sessions.get(sessionId);
return {
sessionId: r.id,
url: r.url,
seed: r.seed,
maxStates: r.max_states,
status: r.status,
startedAt: new Date(r.started_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : undefined,
statesVisited: r.states_visited,
anomaliesFound: r.anomalies_found,
anomalies: live?.anomalies ?? [],
engine: live?.engine,
};
}
return this.sessions.get(sessionId);
}
getAllAnomalies(sessionId, severity) {
if (this.anomalyRepo) {
return this.anomalyRepo.findAll({ sessionId, severity });
}
const all = Array.from(this.sessions.values()).flatMap((s) => s.anomalies);
return all
.filter((a) => !sessionId || this.findSessionForAnomaly(a.id) === sessionId)
.filter((a) => !severity || a.severity === severity);
}
getAnomaly(anomalyId) {
if (this.anomalyRepo) {
return this.anomalyRepo.findById(anomalyId) ?? undefined;
}
for (const session of this.sessions.values()) {
const found = session.anomalies.find((a) => a.id === anomalyId);
if (found)
return found;
}
return undefined;
}
findSessionForAnomaly(anomalyId) {
if (this.anomalyRepo) {
const a = this.anomalyRepo.findById(anomalyId);
return a?.sessionId;
}
for (const session of this.sessions.values()) {
if (session.anomalies.some((a) => a.id === anomalyId))
return session.sessionId;
}
return undefined;
}
screenshotPath(anomalyId) {
const anomaly = this.getAnomaly(anomalyId);
if (!anomaly?.evidence.screenshotPath)
return undefined;
const sessionId = this.findSessionForAnomaly(anomalyId);
if (!sessionId)
return undefined;
return path_1.default.resolve(this.outputDir, anomalyId, anomaly.evidence.screenshotPath);
}
stopSession(sessionId) {
const record = this.sessions.get(sessionId);
if (!record || record.status !== 'running')
return false;
record.engine?.stop();
record.status = 'stopped';
record.finishedAt = new Date().toISOString();
this.sessionRepo?.update(sessionId, { status: 'stopped', finishedAt: Date.now() });
return true;
}
getStats() {
if (this.sessionRepo && this.anomalyRepo) {
const rows = this.sessionRepo.findAll();
return {
totalSessions: rows.length,
totalAnomalies: this.anomalyRepo.count(),
criticalHighCount: this.anomalyRepo.countBySeverity(['high', 'critical']),
runningSessions: rows.filter((r) => r.status === 'running').length,
};
}
const sessions = Array.from(this.sessions.values());
const anomalies = sessions.flatMap((s) => s.anomalies);
return {
totalSessions: sessions.length,
totalAnomalies: anomalies.length,
criticalHighCount: anomalies.filter((a) => a.severity === 'high' || a.severity === 'critical').length,
runningSessions: sessions.filter((s) => s.status === 'running').length,
};
}
async startSession(params) {
const sessionId = `sess_${Date.now()}_${params.seed}`;
const startedAt = new Date().toISOString();
const startedAtMs = Date.now();
const record = {
sessionId,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
status: 'running',
startedAt,
statesVisited: 0,
anomaliesFound: 0,
anomalies: [],
};
this.sessions.set(sessionId, record);
this.sessionRepo?.create({
id: sessionId,
url: params.url,
seed: params.seed,
maxStates: params.maxStates,
startedAt: startedAtMs,
configJson: params.explorationConfig ? JSON.stringify(params.explorationConfig) : '{}',
});
this.emitter('session:started', { sessionId, url: params.url });
const graph = new StateGraph_1.StateGraph();
const agent = new PlaywrightAgent_1.PlaywrightAgent({
seed: params.seed,
explorationConfig: params.explorationConfig,
});
const fuzzingEnabled = params.explorationConfig?.fuzzingEnabled !== false;
const fuzzingIntensity = params.explorationConfig?.fuzzingIntensity ?? 'medium';
const fuzzingPlugin = fuzzingEnabled
? new FuzzingEngine_1.FuzzingEngine({ intensity: fuzzingIntensity, seed: params.seed })
: undefined;
// Build state hooks for visual regression, accessibility, and performance
const stateHooks = [];
// Visual regression hook
if (params.explorationConfig?.visualRegression?.enabled && this.visualRepo) {
const visualCollector = new VisualRegressionCollector_1.VisualRegressionCollector(this.outputDir, this.visualRepo, params.explorationConfig.visualRegression);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
// Take screenshot for visual comparison
const screenshotPath = path_1.default.join(this.outputDir, sid, `visual_${state.id}.png`);
try {
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
await fs_mod.mkdir(path_1.default.dirname(screenshotPath), { recursive: true });
await pw.getPage().screenshot({ path: screenshotPath });
const anomaly = await visualCollector.processScreenshot(screenshotPath, state, sid, actionTrace);
return anomaly ? [anomaly] : [];
}
catch {
return [];
}
});
}
// Accessibility hook
if (params.explorationConfig?.accessibility?.enabled !== false) {
const a11yCollector = new AccessibilityCollector_1.AccessibilityCollector(params.explorationConfig?.accessibility);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
return a11yCollector.collect(pw.getPage(), state.id, sid, actionTrace);
});
}
// Performance hook
if (params.explorationConfig?.performance?.enabled !== false) {
const perfCollector = new PerformanceCollector_1.PerformanceCollector(params.explorationConfig?.performance);
this.performanceMetrics.set(sessionId, []);
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.getPage)
return [];
const { metrics, anomalies } = await perfCollector.collect(pw.getPage(), state.id, sid, actionTrace);
const existing = this.performanceMetrics.get(sid) ?? [];
existing.push(metrics);
this.performanceMetrics.set(sid, existing);
return anomalies;
});
}
// Mobile layout hook (only when a mobile device is emulated)
if (params.explorationConfig?.mobileDevice && params.explorationConfig.mobileDevice !== 'none') {
stateHooks.push(async (state, agentInstance, sid, actionTrace) => {
const pw = agentInstance;
if (!pw.detectMobileLayoutIssues)
return [];
return pw.detectMobileLayoutIssues(state.id, sid, actionTrace);
});
}
const engineConfig = {
graph,
agent,
seed: params.seed,
url: params.url,
maxSteps: params.maxStates,
explorationConfig: params.explorationConfig,
outputDir: this.outputDir,
sessionId,
fuzzingPlugin,
stateHooks,
collectors: [
new ScreenshotCollector_1.ScreenshotCollector(this.outputDir),
new NetworkCollector_1.NetworkCollector(),
new DOMSnapshotCollector_1.DOMSnapshotCollector(this.outputDir),
],
exporters: [new MarkdownExporter_1.MarkdownExporter(), new JSONExporter_1.JSONExporter()],
reproducer: new PlaywrightReproducer_1.PlaywrightReproducer(),
events: {
onSessionStarted: (sid, url) => {
this.emitter('session:started', { sessionId: sid, url });
},
onStateDiscovered: (sid, stateId, url, title) => {
record.statesVisited += 1;
this.sessionRepo?.update(sid, { statesVisited: record.statesVisited });
this.emitter('state:discovered', { sessionId: sid, stateId, url, title });
},
onActionExecuted: (sid, actionType, selector, timestamp) => {
this.emitter('action:executed', { sessionId: sid, actionType, selector, timestamp });
},
onAnomalyDetected: (sid, anomaly) => {
record.anomalies.push(anomaly);
record.anomaliesFound = record.anomalies.length;
this.sessionRepo?.update(sid, { anomaliesFound: record.anomaliesFound });
this.anomalyRepo?.create(anomaly, sid);
if (this.notificationService) {
void this.notificationService.notify(anomaly, sid, params.url);
}
this.emitter('anomaly:detected', {
sessionId: sid,
anomalyId: anomaly.id,
type: anomaly.type,
severity: anomaly.severity,
description: anomaly.description,
});
},
onSessionCompleted: (sid, statesVisited, anomaliesFound) => {
if (record.status === 'running') {
record.status = 'completed';
}
record.finishedAt = new Date().toISOString();
record.statesVisited = statesVisited;
record.anomaliesFound = anomaliesFound;
this.sessionRepo?.update(sid, {
status: record.status,
statesVisited,
anomaliesFound,
finishedAt: Date.now(),
});
this.emitter('session:completed', { sessionId: sid, statesVisited, anomaliesFound });
},
onSessionError: (sid, error) => {
record.status = 'error';
record.finishedAt = new Date().toISOString();
this.sessionRepo?.update(sid, { status: 'error', finishedAt: Date.now() });
this.emitter('session:error', { sessionId: sid, error });
},
},
};
const engine = new ExplorationEngine_1.ExplorationEngine(engineConfig);
record.engine = engine;
// Run in background — do not await
engine.run().catch((err) => {
if (record.status === 'running') {
record.status = 'error';
record.finishedAt = new Date().toISOString();
const msg = err instanceof Error ? err.message : String(err);
this.sessionRepo?.update(sessionId, { status: 'error', finishedAt: Date.now() });
this.emitter('session:error', { sessionId, error: msg });
}
});
return record;
}
async replayAnomaly(anomalyId) {
const replayId = `replay_${Date.now()}`;
const anomaly = this.getAnomaly(anomalyId);
if (!anomaly)
throw new Error(`Anomaly ${anomalyId} not found`);
const sessionId = this.findSessionForAnomaly(anomalyId);
const session = this.getSession(sessionId);
const reproducer = new PlaywrightReproducer_1.PlaywrightReproducer();
const script = reproducer.generateScript(anomaly.actionTrace);
const replayDir = path_1.default.join(this.outputDir, anomalyId, 'replays');
setImmediate(async () => {
try {
const fs_mod = await Promise.resolve().then(() => __importStar(require('fs/promises')));
await fs_mod.mkdir(replayDir, { recursive: true });
const scriptPath = path_1.default.join(replayDir, `${replayId}.ts`);
await fs_mod.writeFile(scriptPath, script, 'utf8');
const agent = new PlaywrightAgent_1.PlaywrightAgent({ seed: session.seed });
await agent.launch(session.url);
for (const action of anomaly.actionTrace) {
await agent.executeAction(action).catch(() => undefined);
}
await agent.close();
}
catch {
// replay errors are non-fatal
}
});
return replayId;
}
}
exports.SessionStore = SessionStore;

View File

@@ -0,0 +1,71 @@
"use strict";
/**
* AIEnrichmentService — selects AI provider and runs enrichment asynchronously.
* Triggered manually (POST /api/anomalies/:id/enrich) or automatically for high/critical.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.AIEnrichmentService = void 0;
const ClaudeProvider_1 = require("./ClaudeProvider");
const OpenAIProvider_1 = require("./OpenAIProvider");
const OllamaProvider_1 = require("./OllamaProvider");
const logger_1 = require("../logger");
class AIEnrichmentService {
constructor(emitter) {
this.emitter = emitter;
this.autoEnrich = process.env['ABE_AI_AUTO_ENRICH'] === 'true';
const minSev = process.env['ABE_AI_MIN_SEVERITY'] ?? 'high';
this.minSeverityRank = AIEnrichmentService.SEVERITY_RANK[minSev] ?? 2;
this.provider = this.createProvider();
}
createProvider() {
const providerName = process.env['ABE_AI_PROVIDER'] ?? 'none';
const model = process.env['ABE_AI_MODEL'];
if (providerName === 'claude') {
const key = process.env['ABE_AI_API_KEY'];
if (!key)
return null;
return new ClaudeProvider_1.ClaudeProvider(key, model);
}
if (providerName === 'openai') {
const key = process.env['ABE_OPENAI_API_KEY'];
if (!key)
return null;
return new OpenAIProvider_1.OpenAIProvider(key, model);
}
if (providerName === 'ollama') {
const url = process.env['ABE_OLLAMA_URL'] ?? 'http://localhost:11434';
return new OllamaProvider_1.OllamaProvider(url, model);
}
return null;
}
/** Check if auto-enrichment should run for this anomaly. */
shouldAutoEnrich(anomaly) {
if (!this.autoEnrich || !this.provider)
return false;
const rank = AIEnrichmentService.SEVERITY_RANK[anomaly.severity] ?? 0;
return rank >= this.minSeverityRank;
}
/** Enrich an anomaly asynchronously and emit WebSocket event when done. */
async enrich(anomaly, context) {
if (!this.provider) {
logger_1.log.warn({ anomalyId: anomaly.id }, 'No AI provider configured');
return;
}
try {
const enrichment = await this.provider.enrich(anomaly, context);
anomaly.aiEnrichment = enrichment;
this.emitter('anomaly:enriched', { anomalyId: anomaly.id, enrichment });
logger_1.log.info({ anomalyId: anomaly.id, provider: this.provider.name }, 'Anomaly enriched');
}
catch (err) {
logger_1.log.error({ anomalyId: anomaly.id, err: err instanceof Error ? err.message : String(err) }, 'AI enrichment failed');
}
}
hasProvider() {
return this.provider !== null;
}
}
exports.AIEnrichmentService = AIEnrichmentService;
AIEnrichmentService.SEVERITY_RANK = {
low: 0, medium: 1, high: 2, critical: 3,
};

View File

@@ -0,0 +1,88 @@
"use strict";
/**
* ClaudeProvider — AI enrichment using Anthropic API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ClaudeProvider = void 0;
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
class ClaudeProvider {
constructor(apiKey, model = DEFAULT_MODEL) {
this.name = 'claude';
this.apiKey = apiKey;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.model,
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const text = data.content.find((c) => c.type === 'text')?.text ?? '';
return parseEnrichment(text, this.name, this.model);
}
}
exports.ClaudeProvider = ClaudeProvider;
function buildPrompt(anomaly, context) {
return `You are a senior software engineer analyzing a bug report from an automated web testing tool.
Bug Report:
- Type: ${anomaly.type}
- Severity: ${anomaly.severity}
- Description: ${anomaly.description}
- URL: ${context.url}
- Page Title: ${context.pageTitle}
- Action Trace: ${JSON.stringify(anomaly.actionTrace.slice(-5), null, 2)}
${context.httpLog.length > 0 ? `- HTTP Log: ${JSON.stringify(context.httpLog.slice(-3), null, 2)}` : ''}
${context.consoleErrors.length > 0 ? `- Console Errors: ${context.consoleErrors.slice(-3).join('\n')}` : ''}
Please provide a concise analysis in exactly this JSON format:
{
"rootCause": "One sentence explaining the likely root cause",
"userImpact": "One sentence describing the impact on users",
"suggestedFix": "One to two sentences with a concrete fix suggestion",
"confidence": "low|medium|high"
}`;
}
function parseEnrichment(text, provider, model) {
const debugPrompt = `Bug analysis:\n${text}`;
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const parsed = JSON.parse(match[0]);
return {
rootCause: parsed.rootCause ?? 'Unknown root cause',
userImpact: parsed.userImpact ?? 'Unknown impact',
suggestedFix: parsed.suggestedFix ?? 'No fix suggested',
debugPrompt,
confidence: parsed.confidence ?? 'medium',
generatedAt: Date.now(),
provider,
model,
};
}
}
catch { /* fallback below */ }
return {
rootCause: text.slice(0, 200) || 'Could not parse root cause',
userImpact: 'See full response',
suggestedFix: 'See full response',
debugPrompt,
confidence: 'low',
generatedAt: Date.now(),
provider,
model,
};
}

View File

@@ -0,0 +1,63 @@
"use strict";
/**
* OllamaProvider — AI enrichment using local Ollama API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OllamaProvider = void 0;
const DEFAULT_MODEL = 'llama3.2';
const DEFAULT_URL = 'http://localhost:11434';
function buildPrompt(anomaly, context) {
return `Analyze this bug and respond ONLY with JSON {"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}.
Bug: ${anomaly.type} (${anomaly.severity}) at ${context.url}
Description: ${anomaly.description}
Last actions: ${anomaly.actionTrace.slice(-3).map((a) => `${a.type} ${a.selector ?? a.url ?? ''}`).join(' → ')}`;
}
class OllamaProvider {
constructor(baseUrl = DEFAULT_URL, model = DEFAULT_MODEL) {
this.name = 'ollama';
this.baseUrl = baseUrl;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.model, prompt, stream: false }),
});
if (!res.ok) {
throw new Error(`Ollama API error: ${res.status}`);
}
const data = await res.json();
const text = data.response ?? '';
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const p = JSON.parse(match[0]);
return {
rootCause: p['rootCause'] ?? 'Unknown',
userImpact: p['userImpact'] ?? 'Unknown',
suggestedFix: p['suggestedFix'] ?? 'None',
debugPrompt: text,
confidence: p['confidence'] ?? 'low',
generatedAt: Date.now(),
provider: 'ollama',
model: this.model,
};
}
}
catch { /* fallback */ }
return {
rootCause: text.slice(0, 200),
userImpact: 'See response',
suggestedFix: 'See response',
debugPrompt: text,
confidence: 'low',
generatedAt: Date.now(),
provider: 'ollama',
model: this.model,
};
}
}
exports.OllamaProvider = OllamaProvider;

View File

@@ -0,0 +1,81 @@
"use strict";
/**
* OpenAIProvider — AI enrichment using OpenAI API.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OpenAIProvider = void 0;
const DEFAULT_MODEL = 'gpt-4o-mini';
function buildPrompt(anomaly, context) {
return `You are a senior software engineer analyzing a bug report.
Bug:
- Type: ${anomaly.type}
- Severity: ${anomaly.severity}
- Description: ${anomaly.description}
- URL: ${context.url}
- Actions: ${JSON.stringify(anomaly.actionTrace.slice(-5))}
${context.httpLog.length > 0 ? `- HTTP: ${JSON.stringify(context.httpLog.slice(-3))}` : ''}
${context.consoleErrors.length > 0 ? `- Errors: ${context.consoleErrors.slice(-3).join('; ')}` : ''}
Respond ONLY with JSON:
{"rootCause":"...","userImpact":"...","suggestedFix":"...","confidence":"low|medium|high"}`;
}
function parseResponse(text, model) {
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const p = JSON.parse(match[0]);
return {
rootCause: p['rootCause'] ?? 'Unknown',
userImpact: p['userImpact'] ?? 'Unknown',
suggestedFix: p['suggestedFix'] ?? 'None',
debugPrompt: text,
confidence: p['confidence'] ?? 'medium',
generatedAt: Date.now(),
provider: 'openai',
model,
};
}
}
catch { /* fallback */ }
return {
rootCause: text.slice(0, 200),
userImpact: 'See response',
suggestedFix: 'See response',
debugPrompt: text,
confidence: 'low',
generatedAt: Date.now(),
provider: 'openai',
model,
};
}
class OpenAIProvider {
constructor(apiKey, model = DEFAULT_MODEL) {
this.name = 'openai';
this.apiKey = apiKey;
this.model = model;
}
async enrich(anomaly, context) {
const prompt = buildPrompt(anomaly, context);
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 512,
response_format: { type: 'json_object' },
}),
});
if (!res.ok) {
throw new Error(`OpenAI API error: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const text = data.choices[0]?.message?.content ?? '';
return parseResponse(text, this.model);
}
}
exports.OpenAIProvider = OpenAIProvider;

199
dist/server/index.js vendored Normal file
View File

@@ -0,0 +1,199 @@
"use strict";
/**
* ABE API Server
* Express + socket.io on port 3001.
* Manages exploration sessions and serves REST + WebSocket API.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createApp = createApp;
exports.createServer = createServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http_1 = __importDefault(require("http"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const socket_io_1 = require("socket.io");
const sessions_1 = require("./routes/sessions");
const anomalies_1 = require("./routes/anomalies");
const config_1 = require("./routes/config");
const schedules_1 = require("./routes/schedules");
const visual_1 = require("./routes/visual");
const SessionStore_1 = require("./SessionStore");
const auth_1 = require("./middleware/auth");
const logger_1 = require("./logger");
const AIEnrichmentService_1 = require("./enrichment/AIEnrichmentService");
const PORT = process.env['ABE_PORT']
? parseInt(process.env['ABE_PORT'], 10)
: process.env['PORT']
? parseInt(process.env['PORT'], 10)
: 3001;
function createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService) {
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
const app = (0, express_1.default)();
app.use((0, cors_1.default)({ origin: corsOrigin }));
app.use(express_1.default.json());
// Health endpoints — no auth required
app.get('/health', (_req, res) => {
const uptime = Math.floor(process.uptime());
res.json({ status: 'ok', version: '0.1.0', uptime_seconds: uptime });
});
app.get('/ready', (_req, res) => {
const stats = store.getStats();
if (dbCheck && !dbCheck()) {
res.status(503).json({ status: 'not_ready', db: 'disconnected', active_sessions: stats.runningSessions });
return;
}
res.json({ status: 'ready', db: 'connected', active_sessions: stats.runningSessions });
});
// Apply API key auth to all /api/* routes
app.use('/api', auth_1.apiKeyAuth);
// Global rate limit: 200 req/min
const globalLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 1000,
max: 200,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', globalLimiter);
// POST /api/sessions rate limit: 20/hour
const sessionCreateLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/sessions', sessionCreateLimiter);
app.get('/api/stats', (_req, res) => {
res.json(store.getStats());
});
app.use('/api/sessions', (0, sessions_1.createSessionRouter)(store));
app.use('/api/anomalies', (0, anomalies_1.createAnomalyRouter)(store, enrichmentService));
app.use('/api/config', (0, config_1.createConfigRouter)());
if (scheduleRepo && scheduler) {
app.use('/api/schedules', (0, schedules_1.createScheduleRouter)(scheduleRepo, scheduler));
}
if (visualRepo) {
app.use('/api/visual', (0, visual_1.createVisualRouter)(visualRepo));
}
// Global error handler
app.use((err, _req, res, _next) => {
const isDev = process.env['NODE_ENV'] !== 'production';
const message = isDev && err instanceof Error ? err.message : 'Internal server error';
res.status(500).json({
error: message,
code: 'INTERNAL_ERROR',
timestamp: Date.now(),
});
});
return app;
}
function createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo) {
const corsOrigin = process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
// Deferred emitter: AIEnrichmentService is created before io, using a closure
let ioEmit = () => undefined;
const enrichmentService = new AIEnrichmentService_1.AIEnrichmentService((event, payload) => ioEmit(event, payload));
const app = createApp(store, dbCheck, scheduleRepo, scheduler, visualRepo, enrichmentService);
const httpServer = http_1.default.createServer(app);
const io = new socket_io_1.Server(httpServer, {
cors: { origin: corsOrigin },
});
// Now wire the real io emitter
ioEmit = (event, payload) => io.emit(event, payload);
io.on('connection', (socket) => {
socket.on('session:stop', (data) => {
store.stopSession(data.sessionId);
});
});
store.setEmitter((event, payload) => {
io.emit(event, payload);
// Auto-enrich high/critical anomalies
if (event === 'anomaly:detected') {
const p = payload;
if (p?.anomalyId) {
const anomaly = store.getAnomaly(p.anomalyId);
if (anomaly && enrichmentService.shouldAutoEnrich(anomaly)) {
const context = {
domSnapshot: '',
httpLog: anomaly.evidence.httpLog ?? [],
consoleErrors: anomaly.evidence.rawErrors ?? [],
actionTrace: anomaly.actionTrace,
pageTitle: '',
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
};
void enrichmentService.enrich(anomaly, context);
}
}
}
});
return httpServer;
}
if (require.main === module) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getDb } = require('../db/connection');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SessionRepository } = require('../db/SessionRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AnomalyRepository } = require('../db/AnomalyRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { NotificationService } = require('./notifications/NotificationService');
const db = getDb();
const sessionRepo = new SessionRepository(db);
const anomalyRepo = new AnomalyRepository(db);
const notificationService = new NotificationService({
persister: (record) => {
db.prepare(`INSERT OR REPLACE INTO notifications (id, anomaly_id, channel, status, sent_at, error)
VALUES (?, ?, ?, ?, ?, ?)`).run(record.id, record.anomalyId, record.channel, record.status, record.sentAt ?? null, record.error ?? null);
},
});
const outputDir = process.env['ABE_REPORTS_DIR'] ?? './reports';
const maxConcurrent = process.env['ABE_MAX_CONCURRENT_SESSIONS']
? parseInt(process.env['ABE_MAX_CONCURRENT_SESSIONS'], 10)
: 3;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { VisualBaselineRepository: VisualRepo } = require('../db/VisualBaselineRepository');
const visualRepo = new VisualRepo(db);
const store = new SessionStore_1.SessionStore(outputDir, sessionRepo, anomalyRepo, maxConcurrent, notificationService, visualRepo);
const dbCheck = () => { try {
db.prepare('SELECT 1').run();
return true;
}
catch {
return false;
} };
// Scheduler
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ScheduleRepository: SchedRepo } = require('../db/ScheduleRepository');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SchedulerService: SchedSvc } = require('./scheduler/SchedulerService');
const scheduleRepo = new SchedRepo(db);
const scheduler = new SchedSvc(scheduleRepo, store);
scheduler.start();
const server = createServer(store, dbCheck, scheduleRepo, scheduler, visualRepo);
// Graceful shutdown
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown)
return;
shuttingDown = true;
logger_1.log.info({ signal }, 'Graceful shutdown initiated');
scheduler.stop();
server.close(() => {
try {
db.close();
}
catch { /* ignore */ }
process.exit(0);
});
setTimeout(() => {
logger_1.log.error('Forced shutdown after 30s');
process.exit(1);
}, 30000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.listen(PORT, () => {
logger_1.log.info({ port: PORT }, 'ABE API server listening');
});
}

13
dist/server/logger.js vendored Normal file
View File

@@ -0,0 +1,13 @@
"use strict";
/**
* Structured logger using pino.
* Log level configurable via ABE_LOG_LEVEL env var (default: 'info').
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.log = void 0;
const pino_1 = __importDefault(require("pino"));
const level = process.env['ABE_LOG_LEVEL'] ?? 'info';
exports.log = (0, pino_1.default)({ level });

21
dist/server/middleware/auth.js vendored Normal file
View File

@@ -0,0 +1,21 @@
"use strict";
/**
* API Key authentication middleware.
* Reads ABE_API_KEY env var; if not set, dev mode (no auth).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.apiKeyAuth = apiKeyAuth;
function apiKeyAuth(req, res, next) {
const apiKey = process.env['ABE_API_KEY'];
if (!apiKey) {
// Dev mode: no auth required
next();
return;
}
const provided = req.headers['x-abe-api-key'];
if (!provided || provided !== apiKey) {
res.status(401).json({ error: 'Invalid or missing API key' });
return;
}
next();
}

View File

@@ -0,0 +1,121 @@
"use strict";
/**
* NotificationService — orchestrates notifiers.
* Called after every anomaly:detected event.
* Persists notification attempts to the notifications table.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.NotificationService = void 0;
const crypto = __importStar(require("crypto"));
const SlackNotifier_1 = require("./SlackNotifier");
const WebhookNotifier_1 = require("./WebhookNotifier");
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
class NotificationService {
constructor(config) {
const slackUrl = config?.slackWebhookUrl ?? process.env['ABE_SLACK_WEBHOOK_URL'];
const webhookUrl = config?.webhookUrl ?? process.env['ABE_WEBHOOK_URL'];
const minSeverity = config?.minSeverity ?? process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high';
const frontendBase = config?.frontendBaseUrl ?? process.env['ABE_CORS_ORIGIN'] ?? 'http://localhost:5173';
if (slackUrl)
this.slack = new SlackNotifier_1.SlackNotifier(slackUrl, frontendBase);
if (webhookUrl)
this.webhook = new WebhookNotifier_1.WebhookNotifier(webhookUrl);
this.minSeverityRank = SEVERITY_RANK[minSeverity] ?? 2;
this.persister = config?.persister;
}
async notify(anomaly, sessionId, targetUrl) {
const anomalySeverityRank = SEVERITY_RANK[anomaly.severity] ?? 0;
if (anomalySeverityRank < this.minSeverityRank)
return;
const sends = [];
if (this.slack) {
sends.push(this.sendWithRetry('slack', anomaly, sessionId, targetUrl));
}
if (this.webhook) {
sends.push(this.sendWithRetry('webhook', anomaly, sessionId, targetUrl));
}
await Promise.allSettled(sends);
}
async sendWithRetry(channel, anomaly, sessionId, targetUrl) {
const record = {
id: crypto.randomUUID(),
anomalyId: anomaly.id,
channel,
status: 'pending',
};
try {
await this.doSend(channel, anomaly, sessionId, targetUrl);
record.status = 'success';
record.sentAt = Date.now();
this.persister?.(record);
}
catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// Retry once after 60s
setTimeout(async () => {
const retryRecord = {
id: crypto.randomUUID(),
anomalyId: anomaly.id,
channel,
status: 'pending',
};
try {
await this.doSend(channel, anomaly, sessionId, targetUrl);
retryRecord.status = 'success';
retryRecord.sentAt = Date.now();
this.persister?.(retryRecord);
}
catch (retryErr) {
retryRecord.status = 'failed';
retryRecord.error = retryErr instanceof Error ? retryErr.message : String(retryErr);
this.persister?.(retryRecord);
}
}, 60000);
record.status = 'failed';
record.error = errMsg;
this.persister?.(record);
}
}
async doSend(channel, anomaly, sessionId, targetUrl) {
if (channel === 'slack' && this.slack) {
await this.slack.send(anomaly, sessionId, targetUrl);
}
else if (channel === 'webhook' && this.webhook) {
await this.webhook.send(anomaly);
}
}
}
exports.NotificationService = NotificationService;

View File

@@ -0,0 +1,57 @@
"use strict";
/**
* SlackNotifier — sends anomaly notifications to a Slack webhook.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SlackNotifier = void 0;
const SEVERITY_EMOJI = {
low: ':blue_circle:',
medium: ':yellow_circle:',
high: ':red_circle:',
critical: ':rotating_light:',
};
class SlackNotifier {
constructor(webhookUrl, frontendBaseUrl = 'http://localhost:5173') {
this.webhookUrl = webhookUrl;
this.frontendBaseUrl = frontendBaseUrl;
}
async send(anomaly, sessionId, targetUrl) {
const emoji = SEVERITY_EMOJI[anomaly.severity] ?? ':warning:';
const payload = {
text: '🐛 ABE found a bug!',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*ABE Bug Report*\n` +
`*Severity:* ${emoji} ${anomaly.severity.toUpperCase()}\n` +
`*Type:* ${anomaly.type}\n` +
`*Description:* ${anomaly.description}\n` +
`*Session:* ${sessionId}\n` +
`*Target:* ${targetUrl}`,
},
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Report' },
url: `${this.frontendBaseUrl}/anomalies/${anomaly.id}`,
},
],
},
],
};
const res = await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`Slack webhook returned ${res.status}: ${await res.text()}`);
}
}
}
exports.SlackNotifier = SlackNotifier;

View File

@@ -0,0 +1,25 @@
"use strict";
/**
* WebhookNotifier — posts full anomaly JSON to a generic webhook URL.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebhookNotifier = void 0;
class WebhookNotifier {
constructor(webhookUrl) {
this.webhookUrl = webhookUrl;
}
async send(anomaly) {
const res = await fetch(this.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ABE-Event': 'anomaly.detected',
},
body: JSON.stringify(anomaly),
});
if (!res.ok) {
throw new Error(`Webhook returned ${res.status}: ${await res.text()}`);
}
}
}
exports.WebhookNotifier = WebhookNotifier;

93
dist/server/routes/anomalies.js vendored Normal file
View File

@@ -0,0 +1,93 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAnomalyRouter = createAnomalyRouter;
const express_1 = require("express");
const fs_1 = __importDefault(require("fs"));
function createAnomalyRouter(store, enrichmentService) {
const router = (0, express_1.Router)();
// GET /api/anomalies — list all anomalies (optionally filtered)
router.get('/', (req, res) => {
const sessionId = req.query['sessionId'];
const severity = req.query['severity'];
const anomalies = store.getAllAnomalies(sessionId, severity);
const mapped = anomalies.map((a) => ({
id: a.id,
sessionId: store.findSessionForAnomaly(a.id),
type: a.type,
severity: a.severity,
description: a.description,
timestamp: a.timestamp,
screenshotUrl: a.evidence.screenshotPath
? `/api/anomalies/${a.id}/screenshot`
: undefined,
}));
res.json(mapped);
});
// GET /api/anomalies/:anomalyId — full anomaly detail
router.get('/:anomalyId', (req, res) => {
const anomalyId = req.params['anomalyId'];
const anomaly = store.getAnomaly(anomalyId);
if (!anomaly) {
res.status(404).json({ error: 'Anomaly not found' });
return;
}
res.json({
...anomaly,
sessionId: store.findSessionForAnomaly(anomaly.id),
screenshotUrl: anomaly.evidence.screenshotPath
? `/api/anomalies/${anomaly.id}/screenshot`
: undefined,
});
});
// GET /api/anomalies/:anomalyId/screenshot — serve PNG
router.get('/:anomalyId/screenshot', (req, res) => {
const anomalyId = req.params['anomalyId'];
const filePath = store.screenshotPath(anomalyId);
if (!filePath || !fs_1.default.existsSync(filePath)) {
res.status(404).json({ error: 'Screenshot not found' });
return;
}
res.setHeader('Content-Type', 'image/png');
fs_1.default.createReadStream(filePath).pipe(res);
});
// POST /api/anomalies/:anomalyId/replay — trigger replay
router.post('/:anomalyId/replay', async (req, res) => {
const anomalyId = req.params['anomalyId'];
try {
const replayId = await store.replayAnomaly(anomalyId);
res.json({ replayId, status: 'running' });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
res.status(404).json({ error: msg });
}
});
// POST /api/anomalies/:anomalyId/enrich — AI enrichment
router.post('/:anomalyId/enrich', async (req, res) => {
const anomalyId = req.params['anomalyId'];
const anomaly = store.getAnomaly(anomalyId);
if (!anomaly) {
res.status(404).json({ error: 'Anomaly not found' });
return;
}
if (!enrichmentService?.hasProvider()) {
res.status(503).json({ error: 'No AI provider configured (set ABE_AI_PROVIDER)' });
return;
}
const context = {
domSnapshot: '',
httpLog: anomaly.evidence.httpLog ?? [],
consoleErrors: anomaly.evidence.rawErrors ?? [],
actionTrace: anomaly.actionTrace,
pageTitle: '',
url: anomaly.actionTrace[anomaly.actionTrace.length - 1]?.url ?? '',
};
// Run async — emit WS event when done
void enrichmentService.enrich(anomaly, context);
res.json({ status: 'enriching', anomalyId });
});
return router;
}

48
dist/server/routes/config.js vendored Normal file
View File

@@ -0,0 +1,48 @@
"use strict";
/**
* Config routes — GET /api/config and PATCH /api/config
* Manages server-side configuration for notifications and defaults.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getServerConfig = getServerConfig;
exports.createConfigRouter = createConfigRouter;
const express_1 = require("express");
const defaultConfig = {
slackWebhookUrl: process.env['ABE_SLACK_WEBHOOK_URL'] ?? null,
notifyMinSeverity: process.env['ABE_NOTIFY_MIN_SEVERITY'] ?? 'high',
defaultMaxStates: 50,
defaultMaxDepth: 5,
defaultActionDelayMs: 500,
defaultExcludedPaths: [],
};
let serverConfig = { ...defaultConfig };
function getServerConfig() {
return { ...serverConfig };
}
function createConfigRouter() {
const router = (0, express_1.Router)();
// GET /api/config — returns current config (without API key)
router.get('/', (_req, res) => {
res.json(serverConfig);
});
// PATCH /api/config — updates config fields
router.patch('/', (req, res) => {
const body = req.body;
const validKeys = [
'slackWebhookUrl',
'notifyMinSeverity',
'defaultMaxStates',
'defaultMaxDepth',
'defaultActionDelayMs',
'defaultExcludedPaths',
];
for (const key of validKeys) {
if (key in body) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serverConfig[key] = body[key];
}
}
res.json(serverConfig);
});
return router;
}

122
dist/server/routes/schedules.js vendored Normal file
View File

@@ -0,0 +1,122 @@
"use strict";
/**
* Schedules routes — CRUD for /api/schedules
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.createScheduleRouter = createScheduleRouter;
const express_1 = require("express");
const crypto = __importStar(require("crypto"));
const cron = __importStar(require("node-cron"));
const SchedulerService_1 = require("../scheduler/SchedulerService");
function createScheduleRouter(scheduleRepo, scheduler) {
const router = (0, express_1.Router)();
// GET /api/schedules
router.get('/', (_req, res) => {
const schedules = scheduleRepo.findAll();
res.json(schedules);
});
// POST /api/schedules
router.post('/', (req, res) => {
const { name, url, config, cronExpression, enabled } = req.body;
if (!name || !url || !cronExpression) {
res.status(400).json({ error: 'name, url, and cronExpression are required' });
return;
}
if (!cron.validate(cronExpression)) {
res.status(400).json({ error: 'Invalid cron expression' });
return;
}
const id = crypto.randomUUID();
const nextRunAt = SchedulerService_1.SchedulerService.computeNextRunAt(cronExpression);
scheduleRepo.create({
id,
name,
url,
configJson: JSON.stringify(config ?? {}),
cronExpression,
enabled: enabled !== false,
nextRunAt: nextRunAt ?? undefined,
});
const record = scheduleRepo.findById(id);
if (record.enabled) {
scheduler.register(record);
}
res.status(201).json(record);
});
// PATCH /api/schedules/:id
router.patch('/:id', (req, res) => {
const id = String(req.params['id']);
const existing = scheduleRepo.findById(id);
if (!existing) {
res.status(404).json({ error: 'Schedule not found' });
return;
}
const { name, url, config, cronExpression, enabled } = req.body;
if (cronExpression !== undefined && !cron.validate(cronExpression)) {
res.status(400).json({ error: 'Invalid cron expression' });
return;
}
scheduleRepo.update(id, {
name,
url,
configJson: config !== undefined ? JSON.stringify(config) : undefined,
cronExpression,
enabled,
});
const updated = scheduleRepo.findById(id);
// Re-register/unregister cron job
if (updated.enabled) {
scheduler.register(updated);
}
else {
scheduler.unregister(id);
}
res.json(updated);
});
// DELETE /api/schedules/:id
router.delete('/:id', (req, res) => {
const id = String(req.params['id']);
const existing = scheduleRepo.findById(id);
if (!existing) {
res.status(404).json({ error: 'Schedule not found' });
return;
}
scheduler.unregister(String(id));
scheduleRepo.delete(String(id));
res.status(204).send();
});
return router;
}

104
dist/server/routes/sessions.js vendored Normal file
View File

@@ -0,0 +1,104 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSessionRouter = createSessionRouter;
const express_1 = require("express");
const ExplorationConfig_1 = require("../../core/ExplorationConfig");
function createSessionRouter(store) {
const router = (0, express_1.Router)();
// POST /api/sessions — start a new exploration
router.post('/', async (req, res) => {
const body = req.body;
const { url, seed = 42 } = body;
if (!url || typeof url !== 'string') {
res.status(400).json({ error: 'url is required' });
return;
}
// Enforce concurrent session limit
const stats = store.getStats();
const limit = store.getMaxConcurrent();
if (stats.runningSessions >= limit) {
res.status(429).json({
error: 'Max concurrent sessions reached',
active: stats.runningSessions,
limit,
});
return;
}
const config = {
...ExplorationConfig_1.DEFAULT_EXPLORATION_CONFIG,
...(body.config ?? {}),
};
// If allowedDomains not specified, derive from the target URL
if (config.allowedDomains.length === 0) {
try {
const hostname = new URL(url).hostname;
config.allowedDomains = [hostname];
}
catch {
// leave empty
}
}
const record = await store.startSession({
url,
seed,
maxStates: config.maxStates,
explorationConfig: config,
});
res.status(201).json({
sessionId: record.sessionId,
status: record.status,
startedAt: record.startedAt,
});
});
// GET /api/sessions — list all sessions
router.get('/', (_req, res) => {
const sessions = store.getAllSessions().map((s) => ({
sessionId: s.sessionId,
url: s.url,
status: s.status,
startedAt: s.startedAt,
anomaliesFound: s.anomaliesFound,
statesVisited: s.statesVisited,
}));
res.json(sessions);
});
// GET /api/sessions/:sessionId — session detail
router.get('/:sessionId', (req, res) => {
const record = store.getSession(req.params['sessionId']);
if (!record) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json({
sessionId: record.sessionId,
url: record.url,
status: record.status,
startedAt: record.startedAt,
finishedAt: record.finishedAt,
statesVisited: record.statesVisited,
anomaliesFound: record.anomaliesFound,
seed: record.seed,
});
});
// DELETE /api/sessions/:sessionId — stop an active session
router.delete('/:sessionId', (req, res) => {
const stopped = store.stopSession(req.params['sessionId']);
if (!stopped) {
res.status(404).json({ error: 'Session not found or not running' });
return;
}
res.json({ stopped: true });
});
// GET /api/sessions/:sessionId/performance — performance metrics for session
router.get('/:sessionId/performance', (req, res) => {
const sessionId = req.params['sessionId'];
const record = store.getSession(sessionId);
if (!record) {
res.status(404).json({ error: 'Session not found' });
return;
}
const metrics = store.getPerformanceMetrics(sessionId);
res.json(metrics);
});
return router;
}

52
dist/server/routes/visual.js vendored Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
/**
* Visual regression routes — /api/visual
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.createVisualRouter = createVisualRouter;
const express_1 = require("express");
function createVisualRouter(repo) {
const router = (0, express_1.Router)();
// GET /api/visual/comparisons
router.get('/comparisons', (req, res) => {
const sessionId = req.query['sessionId'];
const status = req.query['status'];
const comparisons = repo.findComparisons({ sessionId, status });
res.json(comparisons);
});
// POST /api/visual/baselines/:comparisonId/approve
router.post('/baselines/:comparisonId/approve', (req, res) => {
const comparisonId = String(req.params['comparisonId']);
const comparison = repo.findComparisonById(comparisonId);
if (!comparison) {
res.status(404).json({ error: 'Comparison not found' });
return;
}
const baselineId = repo.promoteToBaseline(comparisonId);
res.json({ baselineId, status: 'approved' });
});
// POST /api/visual/baselines/:comparisonId/reject
router.post('/baselines/:comparisonId/reject', (req, res) => {
const comparisonId = String(req.params['comparisonId']);
const comparison = repo.findComparisonById(comparisonId);
if (!comparison) {
res.status(404).json({ error: 'Comparison not found' });
return;
}
repo.updateComparisonStatus(comparisonId, 'failed');
res.json({ status: 'rejected' });
});
// POST /api/visual/baselines/approve-all
router.post('/baselines/approve-all', (req, res) => {
const { sessionId } = req.body;
const pending = repo.findComparisons({ sessionId, status: 'new_state' });
const approved = [];
for (const comp of pending) {
const id = repo.promoteToBaseline(comp.id);
if (id)
approved.push(id);
}
res.json({ approved: approved.length });
});
return router;
}

View File

@@ -0,0 +1,140 @@
"use strict";
/**
* SchedulerService — manages cron-based scheduled explorations.
* Loads schedules from DB on startup, registers cron jobs, and triggers sessions.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SchedulerService = void 0;
const cron = __importStar(require("node-cron"));
const logger_1 = require("../logger");
class SchedulerService {
constructor(scheduleRepo, sessionStore) {
this.scheduleRepo = scheduleRepo;
this.sessionStore = sessionStore;
this.jobs = new Map();
}
/** Load all enabled schedules and start cron jobs. */
start() {
const schedules = this.scheduleRepo.findAll(true);
for (const schedule of schedules) {
this.register(schedule);
}
logger_1.log.info({ count: schedules.length }, 'SchedulerService started');
}
/** Stop all cron jobs. */
stop() {
for (const [id, task] of this.jobs) {
task.stop();
logger_1.log.info({ scheduleId: id }, 'Cron job stopped');
}
this.jobs.clear();
}
/** Register (or re-register) a cron job for a schedule. */
register(schedule) {
this.unregister(schedule.id);
if (!schedule.enabled)
return;
if (!cron.validate(schedule.cronExpression)) {
logger_1.log.warn({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Invalid cron expression, skipping');
return;
}
const task = cron.schedule(schedule.cronExpression, () => {
void this.fire(schedule.id);
});
this.jobs.set(schedule.id, task);
logger_1.log.info({ scheduleId: schedule.id, cron: schedule.cronExpression }, 'Cron job registered');
}
/** Unregister a cron job. */
unregister(scheduleId) {
const existing = this.jobs.get(scheduleId);
if (existing) {
existing.stop();
this.jobs.delete(scheduleId);
}
}
/** Fire a scheduled run. */
async fire(scheduleId) {
const schedule = this.scheduleRepo.findById(scheduleId);
if (!schedule || !schedule.enabled)
return;
// Check if a session from this schedule is still running
const running = this.sessionStore.getAllSessions().filter((s) => s.status === 'running');
if (running.length > 0) {
// Check if any running session was created from this schedule
const scheduleConfig = JSON.parse(schedule.configJson);
const alreadyRunning = running.some((s) => {
try {
const cfg = JSON.parse(s.config_json ?? '{}');
return cfg.scheduleId === scheduleId;
}
catch {
return false;
}
});
if (alreadyRunning) {
logger_1.log.warn({ scheduleId }, 'Previous session still running, skipping scheduled tick');
return;
}
void scheduleConfig; // suppress unused warning
}
logger_1.log.info({ scheduleId, url: schedule.url }, 'Firing scheduled exploration');
const now = Date.now();
this.scheduleRepo.update(scheduleId, { lastRunAt: now });
try {
const config = JSON.parse(schedule.configJson);
// Inject scheduleId into config for tracking
config.scheduleId = scheduleId;
await this.sessionStore.startSession({
url: schedule.url,
seed: Math.floor(Math.random() * 0x7fffffff),
maxStates: config.maxStates ?? 50,
explorationConfig: config,
});
}
catch (err) {
logger_1.log.error({ scheduleId, err: err instanceof Error ? err.message : String(err) }, 'Scheduled session failed to start');
}
}
/** Compute approximate next run time for a cron expression. */
static computeNextRunAt(cronExpression) {
if (!cron.validate(cronExpression))
return null;
// Simple heuristic: use current time + 60s as a placeholder
// A proper implementation would parse the cron and compute the next trigger
return Date.now() + 60000;
}
}
exports.SchedulerService = SchedulerService;

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3001:3001"
env_file:
- .env
environment:
- PORT=3001
volumes:
- ./reports:/app/reports
- ./logs:/app/logs
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
start_period: 5s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
name: abe-network

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

20
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production stage ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Single-page application fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy REST API to the backend service
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy socket.io with WebSocket upgrade support
location /socket.io/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}

5404
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More