docs: enterprise refactor plan with ralph specs
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
logs
|
||||
reports
|
||||
data
|
||||
.ralph
|
||||
tests
|
||||
frontend
|
||||
.git
|
||||
*.log
|
||||
12
.env.example
Normal file
12
.env.example
Normal 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
48
.github/workflows/abe-example.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
||||
.ralph/docs/generated/*
|
||||
!.ralph/docs/generated/.gitkeep
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# General logs
|
||||
*.log
|
||||
|
||||
|
||||
1
.ralph/.loop_start_sha
Normal file
1
.ralph/.loop_start_sha
Normal file
@@ -0,0 +1 @@
|
||||
4c92712d204993bdd6ff2a7b60583e28ac4f87b1
|
||||
185
.ralph/AGENT.md
185
.ralph/AGENT.md
@@ -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.
|
||||
|
||||
410
.ralph/PROMPT.md
410
.ralph/PROMPT.md
@@ -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."
|
||||
|
||||
@@ -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
1
.ralph/progress.json
Normal file
@@ -0,0 +1 @@
|
||||
{"status": "completed", "timestamp": "2026-03-04 04:32:44"}
|
||||
130
.ralph/specs/legacy/ai-enrichment.md
Normal file
130
.ralph/specs/legacy/ai-enrichment.md
Normal 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.
|
||||
59
.ralph/specs/legacy/api-security.md
Normal file
59
.ralph/specs/legacy/api-security.md
Normal 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.
|
||||
187
.ralph/specs/legacy/api-server.md
Normal file
187
.ralph/specs/legacy/api-server.md
Normal 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" }
|
||||
```
|
||||
118
.ralph/specs/legacy/cli-cicd.md
Normal file
118
.ralph/specs/legacy/cli-cicd.md
Normal 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
|
||||
```
|
||||
99
.ralph/specs/legacy/database.md
Normal file
99
.ralph/specs/legacy/database.md
Normal 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
|
||||
102
.ralph/specs/legacy/docker.md
Normal file
102
.ralph/specs/legacy/docker.md
Normal 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
|
||||
84
.ralph/specs/legacy/exploration-config.md
Normal file
84
.ralph/specs/legacy/exploration-config.md
Normal 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
72
.ralph/specs/legacy/frontend-v2.md
Normal file
72
.ralph/specs/legacy/frontend-v2.md
Normal 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
|
||||
99
.ralph/specs/legacy/frontend.md
Normal file
99
.ralph/specs/legacy/frontend.md
Normal 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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
94
.ralph/specs/legacy/fuzzing.md
Normal file
94
.ralph/specs/legacy/fuzzing.md
Normal 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.
|
||||
164
.ralph/specs/legacy/interfaces.md
Normal file
164
.ralph/specs/legacy/interfaces.md
Normal 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
|
||||
}
|
||||
```
|
||||
119
.ralph/specs/legacy/multi-browser-accessibility.md
Normal file
119
.ralph/specs/legacy/multi-browser-accessibility.md
Normal 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
|
||||
88
.ralph/specs/legacy/network-chaos.md
Normal file
88
.ralph/specs/legacy/network-chaos.md
Normal 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
|
||||
64
.ralph/specs/legacy/notifications.md
Normal file
64
.ralph/specs/legacy/notifications.md
Normal 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.
|
||||
130
.ralph/specs/legacy/output-format.md
Normal file
130
.ralph/specs/legacy/output-format.md
Normal 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}
|
||||
```
|
||||
124
.ralph/specs/legacy/performance-metrics.md
Normal file
124
.ralph/specs/legacy/performance-metrics.md
Normal 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
|
||||
}
|
||||
```
|
||||
77
.ralph/specs/legacy/production-hardening.md
Normal file
77
.ralph/specs/legacy/production-hardening.md
Normal 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').
|
||||
138
.ralph/specs/legacy/project-structure.md
Normal file
138
.ralph/specs/legacy/project-structure.md
Normal 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'],
|
||||
};
|
||||
```
|
||||
79
.ralph/specs/legacy/scheduled-monitoring.md
Normal file
79
.ralph/specs/legacy/scheduled-monitoring.md
Normal 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}"
|
||||
124
.ralph/specs/legacy/visual-regression.md
Normal file
124
.ralph/specs/legacy/visual-regression.md
Normal 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
|
||||
}
|
||||
```
|
||||
134
.ralph/specs/phase-01-shared-domain.md
Normal file
134
.ralph/specs/phase-01-shared-domain.md
Normal 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
|
||||
136
.ralph/specs/phase-02-shared-infrastructure.md
Normal file
136
.ralph/specs/phase-02-shared-infrastructure.md
Normal 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
|
||||
|
||||
216
.ralph/specs/phase-07-api-server.md
Normal file
216
.ralph/specs/phase-07-api-server.md
Normal 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
|
||||
|
||||
66
.ralph/specs/phase-08-job-queue.md
Normal file
66
.ralph/specs/phase-08-job-queue.md
Normal 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).
|
||||
148
.ralph/specs/phase-09-auth-module.md
Normal file
148
.ralph/specs/phase-09-auth-module.md
Normal 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.
|
||||
129
.ralph/specs/phase-10-frontend-shell.md
Normal file
129
.ralph/specs/phase-10-frontend-shell.md
Normal 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
59
CLAUDE.md
Normal 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
46
Dockerfile
Normal 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
127
README.md
@@ -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
BIN
data/abe.db
Normal file
Binary file not shown.
BIN
data/abe.db-shm
Normal file
BIN
data/abe.db-shm
Normal file
Binary file not shown.
BIN
data/abe.db-wal
Normal file
BIN
data/abe.db-wal
Normal file
Binary file not shown.
252
dist/cli.js
vendored
Normal file
252
dist/cli.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
program.parse(process.argv);
|
||||
137
dist/core/AnomalyDetector.js
vendored
Normal file
137
dist/core/AnomalyDetector.js
vendored
Normal 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
53
dist/core/ExplorationConfig.js
vendored
Normal 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
197
dist/core/ExplorationEngine.js
vendored
Normal 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
66
dist/core/Logger.js
vendored
Normal 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
83
dist/core/StateGraph.js
vendored
Normal 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
6
dist/core/interfaces.js
vendored
Normal 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
76
dist/db/AnomalyRepository.js
vendored
Normal 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
82
dist/db/ScheduleRepository.js
vendored
Normal 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
53
dist/db/SessionRepository.js
vendored
Normal 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
77
dist/db/VisualBaselineRepository.js
vendored
Normal 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
43
dist/db/connection.js
vendored
Normal 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
126
dist/db/migrations.js
vendored
Normal 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
86
dist/index.js
vendored
Normal 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
501
dist/plugins/agents/PlaywrightAgent.js
vendored
Normal 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;
|
||||
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal file
124
dist/plugins/collectors/AccessibilityCollector.js
vendored
Normal 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;
|
||||
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal file
56
dist/plugins/collectors/DOMSnapshotCollector.js
vendored
Normal 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;
|
||||
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal file
18
dist/plugins/collectors/NetworkCollector.js
vendored
Normal 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;
|
||||
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal file
177
dist/plugins/collectors/PerformanceCollector.js
vendored
Normal 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;
|
||||
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal file
63
dist/plugins/collectors/ScreenshotCollector.js
vendored
Normal 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;
|
||||
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal file
155
dist/plugins/collectors/VisualRegressionCollector.js
vendored
Normal 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
97
dist/plugins/exporters/JSONExporter.js
vendored
Normal 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;
|
||||
113
dist/plugins/exporters/MarkdownExporter.js
vendored
Normal file
113
dist/plugins/exporters/MarkdownExporter.js
vendored
Normal 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
139
dist/plugins/fuzzers/FuzzingEngine.js
vendored
Normal 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;
|
||||
52
dist/plugins/fuzzers/InputTypeDetector.js
vendored
Normal file
52
dist/plugins/fuzzers/InputTypeDetector.js
vendored
Normal 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';
|
||||
}
|
||||
26
dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js
vendored
Normal file
26
dist/plugins/fuzzers/strategies/BoundaryValueStrategy.js
vendored
Normal 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;
|
||||
19
dist/plugins/fuzzers/strategies/EmptyValueStrategy.js
vendored
Normal file
19
dist/plugins/fuzzers/strategies/EmptyValueStrategy.js
vendored
Normal 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;
|
||||
28
dist/plugins/fuzzers/strategies/OversizedStringStrategy.js
vendored
Normal file
28
dist/plugins/fuzzers/strategies/OversizedStringStrategy.js
vendored
Normal 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;
|
||||
26
dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js
vendored
Normal file
26
dist/plugins/fuzzers/strategies/SpecialCharsStrategy.js
vendored
Normal 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;
|
||||
31
dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js
vendored
Normal file
31
dist/plugins/fuzzers/strategies/TypeMismatchStrategy.js
vendored
Normal 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
7
dist/plugins/interfaces.js
vendored
Normal 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 });
|
||||
59
dist/plugins/reproducers/PlaywrightReproducer.js
vendored
Normal file
59
dist/plugins/reproducers/PlaywrightReproducer.js
vendored
Normal 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
84
dist/replay.js
vendored
Normal 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
398
dist/server/SessionStore.js
vendored
Normal 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;
|
||||
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal file
71
dist/server/enrichment/AIEnrichmentService.js
vendored
Normal 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,
|
||||
};
|
||||
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal file
88
dist/server/enrichment/ClaudeProvider.js
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal file
63
dist/server/enrichment/OllamaProvider.js
vendored
Normal 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;
|
||||
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal file
81
dist/server/enrichment/OpenAIProvider.js
vendored
Normal 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
199
dist/server/index.js
vendored
Normal 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
13
dist/server/logger.js
vendored
Normal 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
21
dist/server/middleware/auth.js
vendored
Normal 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();
|
||||
}
|
||||
121
dist/server/notifications/NotificationService.js
vendored
Normal file
121
dist/server/notifications/NotificationService.js
vendored
Normal 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;
|
||||
57
dist/server/notifications/SlackNotifier.js
vendored
Normal file
57
dist/server/notifications/SlackNotifier.js
vendored
Normal 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;
|
||||
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal file
25
dist/server/notifications/WebhookNotifier.js
vendored
Normal 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
93
dist/server/routes/anomalies.js
vendored
Normal 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
48
dist/server/routes/config.js
vendored
Normal 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
122
dist/server/routes/schedules.js
vendored
Normal 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
104
dist/server/routes/sessions.js
vendored
Normal 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
52
dist/server/routes/visual.js
vendored
Normal 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;
|
||||
}
|
||||
140
dist/server/scheduler/SchedulerService.js
vendored
Normal file
140
dist/server/scheduler/SchedulerService.js
vendored
Normal 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
37
docker-compose.yml
Normal 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
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
20
frontend/Dockerfile
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
28
frontend/nginx.conf
Normal 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
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
Reference in New Issue
Block a user