Wire all integrations + production hardening: 15 recommendations
Integration & Wiring: - useStore/useAppState wired into App.tsx (replaces 8 useState calls) - React Router wired at app root (URL-based navigation) - SparklineChart/MetricCard/BarChart integrated into Admin + Ethics pages - useNotifications.handleWSEvent wired into WebSocket handler - Notification center dropdown in header with unread badge - Locale selector added to Settings page (6 languages) - Dashboard data fetching with 10s polling into MetricCards - File drag-and-drop support on chat area Production Hardening: - PostgresStateBackend with connection pooling (psycopg2) - App lifespan wires backend from FUSIONAGI_DB_BACKEND env (memory|sqlite|postgres) - Redis cache wired from FUSIONAGI_REDIS_URL env at startup - Multi-process uvicorn config for horizontal scaling Testing: - Playwright visual regression tests (12 stories x 2 viewports) - k6 load test script with ramp/spike/ramp-down stages - 7 new Python tests (postgres fallback, app wiring) 575 Python tests + 45 frontend tests = 620 total, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
124
tests/load/k6_prompt.js
Normal file
124
tests/load/k6_prompt.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* k6 load test for FusionAGI prompt endpoint.
|
||||
*
|
||||
* Run:
|
||||
* k6 run tests/load/k6_prompt.js
|
||||
*
|
||||
* Options:
|
||||
* k6 run --vus 10 --duration 30s tests/load/k6_prompt.js
|
||||
* k6 run --vus 50 --duration 2m tests/load/k6_prompt.js
|
||||
*
|
||||
* Requires:
|
||||
* - FusionAGI API running at http://localhost:8000
|
||||
* - k6 installed (https://k6.io/docs/getting-started/installation/)
|
||||
*/
|
||||
|
||||
import http from 'k6/http'
|
||||
import { check, sleep } from 'k6'
|
||||
import { Rate, Trend } from 'k6/metrics'
|
||||
|
||||
// Custom metrics
|
||||
const errorRate = new Rate('errors')
|
||||
const promptDuration = new Trend('prompt_duration', true)
|
||||
const sessionDuration = new Trend('session_duration', true)
|
||||
|
||||
// Test configuration
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '10s', target: 5 }, // ramp up
|
||||
{ duration: '30s', target: 10 }, // steady
|
||||
{ duration: '10s', target: 20 }, // spike
|
||||
{ duration: '10s', target: 0 }, // ramp down
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<5000'], // 95% under 5s
|
||||
errors: ['rate<0.1'], // <10% error rate
|
||||
},
|
||||
}
|
||||
|
||||
const BASE_URL = __ENV.API_URL || 'http://localhost:8000'
|
||||
const API_KEY = __ENV.API_KEY || ''
|
||||
|
||||
const PROMPTS = [
|
||||
'Explain the concept of recursion',
|
||||
'What are the benefits of microservices?',
|
||||
'Design a rate limiter',
|
||||
'Compare SQL and NoSQL databases',
|
||||
'Explain the CAP theorem',
|
||||
'What is eventual consistency?',
|
||||
'How does garbage collection work?',
|
||||
'Explain WebSocket vs HTTP polling',
|
||||
]
|
||||
|
||||
function getHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (API_KEY) {
|
||||
headers['Authorization'] = `Bearer ${API_KEY}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const headers = getHeaders()
|
||||
|
||||
// 1. Create session
|
||||
const sessionStart = Date.now()
|
||||
const sessionRes = http.post(`${BASE_URL}/v1/sessions`, null, { headers })
|
||||
sessionDuration.add(Date.now() - sessionStart)
|
||||
|
||||
const sessionOk = check(sessionRes, {
|
||||
'session created': (r) => r.status === 200 || r.status === 201,
|
||||
'session has id': (r) => {
|
||||
try { return !!JSON.parse(r.body).session_id } catch { return false }
|
||||
},
|
||||
})
|
||||
|
||||
if (!sessionOk) {
|
||||
errorRate.add(1)
|
||||
sleep(1)
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = JSON.parse(sessionRes.body).session_id
|
||||
const prompt = PROMPTS[Math.floor(Math.random() * PROMPTS.length)]
|
||||
|
||||
// 2. Send prompt
|
||||
const promptStart = Date.now()
|
||||
const promptRes = http.post(
|
||||
`${BASE_URL}/v1/sessions/${sessionId}/prompt`,
|
||||
JSON.stringify({ prompt }),
|
||||
{ headers, timeout: '30s' },
|
||||
)
|
||||
promptDuration.add(Date.now() - promptStart)
|
||||
|
||||
const promptOk = check(promptRes, {
|
||||
'prompt success': (r) => r.status === 200,
|
||||
'has final_answer': (r) => {
|
||||
try { return !!JSON.parse(r.body).final_answer } catch { return false }
|
||||
},
|
||||
})
|
||||
|
||||
if (!promptOk) {
|
||||
errorRate.add(1)
|
||||
}
|
||||
|
||||
// 3. Health check
|
||||
const healthRes = http.get(`${BASE_URL}/health`, { headers })
|
||||
check(healthRes, {
|
||||
'health ok': (r) => r.status === 200,
|
||||
})
|
||||
|
||||
sleep(0.5 + Math.random())
|
||||
}
|
||||
|
||||
export function handleSummary(data) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
total_requests: data.metrics.http_reqs.values.count,
|
||||
avg_duration_ms: Math.round(data.metrics.http_req_duration.values.avg),
|
||||
p95_duration_ms: Math.round(data.metrics.http_req_duration.values['p(95)']),
|
||||
error_rate: data.metrics.errors ? data.metrics.errors.values.rate : 0,
|
||||
avg_prompt_ms: data.metrics.prompt_duration ? Math.round(data.metrics.prompt_duration.values.avg) : 0,
|
||||
}, null, 2),
|
||||
}
|
||||
}
|
||||
34
tests/test_app_wiring.py
Normal file
34
tests/test_app_wiring.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for app lifespan backend/cache wiring."""
|
||||
|
||||
|
||||
from fusionagi.api.app import create_app
|
||||
|
||||
|
||||
def test_create_app_default():
|
||||
"""App should create successfully with default (memory) backend."""
|
||||
app = create_app()
|
||||
assert app is not None
|
||||
assert app.title == "FusionAGI Dvādaśa API"
|
||||
|
||||
|
||||
def test_create_app_with_sqlite_env(tmp_path, monkeypatch):
|
||||
"""App should accept FUSIONAGI_DB_BACKEND=sqlite env."""
|
||||
monkeypatch.setenv("FUSIONAGI_DB_BACKEND", "sqlite")
|
||||
monkeypatch.setenv("FUSIONAGI_SQLITE_PATH", str(tmp_path / "test.db"))
|
||||
app = create_app()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_create_app_with_invalid_postgres(monkeypatch):
|
||||
"""App should gracefully fall back when Postgres DSN is invalid."""
|
||||
monkeypatch.setenv("FUSIONAGI_DB_BACKEND", "postgres")
|
||||
monkeypatch.setenv("FUSIONAGI_POSTGRES_DSN", "postgresql://invalid:invalid@localhost:1/invalid")
|
||||
app = create_app()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_create_app_with_invalid_redis(monkeypatch):
|
||||
"""App should gracefully fall back when Redis URL is invalid."""
|
||||
monkeypatch.setenv("FUSIONAGI_REDIS_URL", "redis://localhost:1/0")
|
||||
app = create_app()
|
||||
assert app is not None
|
||||
30
tests/test_postgres_backend.py
Normal file
30
tests/test_postgres_backend.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Tests for PostgresStateBackend graceful degradation.
|
||||
|
||||
When psycopg2 is unavailable, all operations are no-ops.
|
||||
"""
|
||||
|
||||
from fusionagi.core.postgres_backend import PostgresStateBackend
|
||||
from fusionagi.schemas.task import Task, TaskState
|
||||
|
||||
|
||||
def test_graceful_fallback_without_psycopg2():
|
||||
"""PostgresStateBackend should silently degrade when Postgres is unreachable."""
|
||||
backend = PostgresStateBackend(dsn="postgresql://invalid:invalid@localhost:1/invalid")
|
||||
assert backend._available is False
|
||||
|
||||
# All reads return None/empty
|
||||
assert backend.get_task("t1") is None
|
||||
assert backend.get_task_state("t1") is None
|
||||
assert backend.get_trace("t1") == []
|
||||
assert backend.list_tasks() == []
|
||||
assert backend.count_tasks() == 0
|
||||
|
||||
# All writes are no-ops
|
||||
backend.set_task(Task(task_id="t1", goal="test"))
|
||||
backend.set_task_state("t1", TaskState.ACTIVE)
|
||||
backend.append_trace("t1", {"step": 1})
|
||||
assert backend.delete_task("t1") is False
|
||||
|
||||
# Close is safe
|
||||
backend.close()
|
||||
assert backend._available is False
|
||||
Reference in New Issue
Block a user