Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba30b1dc6 | ||
|
|
a3d0648d8a | ||
|
|
2d56ffec45 | ||
|
|
c2936af9a9 | ||
|
|
cf6b57b2cd | ||
|
|
b2a4e67db6 | ||
|
|
3606a46e59 | ||
|
|
1bae7e32d3 | ||
|
|
92f7251384 | ||
|
|
cb39586c23 |
@@ -31,6 +31,12 @@ HINDSIGHT_API_HOST=0.0.0.0
|
||||
HINDSIGHT_API_PORT=8888
|
||||
HINDSIGHT_API_LOG_LEVEL=info
|
||||
|
||||
# Base Path / Reverse Proxy Support (Optional)
|
||||
# Set these when deploying behind a reverse proxy with path-based routing
|
||||
# Example: To deploy at example.com/hindsight/, set both to "/hindsight"
|
||||
# HINDSIGHT_API_BASE_PATH=/hindsight
|
||||
# NEXT_PUBLIC_BASE_PATH=/hindsight
|
||||
|
||||
# Database (Optional - uses embedded pg0 by default)
|
||||
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Nginx Reverse Proxy with Custom Base Path
|
||||
|
||||
Deploy Hindsight API under `/hindsight` (or any custom path) using Nginx reverse proxy.
|
||||
|
||||
## Quick Start (Published Image - API Only)
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
- **API:** http://localhost:8080/hindsight/docs
|
||||
- **Control Plane:** http://localhost:9999 (direct access, not proxied)
|
||||
|
||||
## Full Stack with Custom Base Path (Requires Build)
|
||||
|
||||
**Important:** You cannot rebuild from the published image with build args. You must build from source.
|
||||
|
||||
### Build from Source with Custom Base Path
|
||||
|
||||
1. **Clone the repository** (if you haven't):
|
||||
```bash
|
||||
git clone https://github.com/vectorize-io/hindsight.git
|
||||
cd hindsight
|
||||
```
|
||||
|
||||
2. **Build with base path**:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_PATH=/hindsight \
|
||||
-f docker/standalone/Dockerfile \
|
||||
-t hindsight:custom \
|
||||
.
|
||||
```
|
||||
|
||||
3. **Update docker-compose.yml** to use your built image:
|
||||
```yaml
|
||||
services:
|
||||
hindsight:
|
||||
image: hindsight:custom # ← Change this
|
||||
environment:
|
||||
HINDSIGHT_API_BASE_PATH: /hindsight
|
||||
NEXT_PUBLIC_BASE_PATH: /hindsight
|
||||
```
|
||||
|
||||
4. **Update nginx.conf** to handle Control Plane routes (see below)
|
||||
|
||||
5. **Run**:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
### Required nginx.conf for Full Stack
|
||||
|
||||
Replace the current `nginx.conf` with this to proxy both API and Control Plane:
|
||||
|
||||
```nginx
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
upstream hindsight_api { server hindsight:8888; }
|
||||
upstream hindsight_cp { server hindsight:9999; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# API
|
||||
location ~ ^/hindsight/(docs|openapi\.json|health|metrics|v1|mcp) {
|
||||
proxy_pass http://hindsight_api;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
# Control Plane static files
|
||||
location ~ ^/hindsight/_next/ {
|
||||
proxy_pass http://hindsight_cp;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
# Control Plane UI
|
||||
location /hindsight {
|
||||
proxy_pass http://hindsight_cp;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
location = / { return 301 /hindsight; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why Build is Required
|
||||
|
||||
Next.js requires `basePath` at **build time**. The published image was built without a custom base path, so you must rebuild from source with the `NEXT_PUBLIC_BASE_PATH` build arg to deploy the Control Plane under a subpath.
|
||||
|
||||
The API works without rebuild because `HINDSIGHT_API_BASE_PATH` is a runtime environment variable.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Hindsight API deployment with Nginx reverse proxy (API-only)
|
||||
#
|
||||
# This example deploys Hindsight API under the path /hindsight with:
|
||||
# - Hindsight standalone image (API + Control Plane + embedded pg0)
|
||||
# - Nginx reverse proxy (API only)
|
||||
#
|
||||
# Quick Start:
|
||||
# docker-compose -f docker/docker-compose/nginx/docker-compose.yml up
|
||||
#
|
||||
# Access:
|
||||
# API (via nginx): http://localhost:8080/hindsight/docs
|
||||
# Control Plane (direct): http://localhost:9999
|
||||
#
|
||||
# For full stack deployment (API + Control Plane both under /hindsight):
|
||||
# See README.md in this directory for instructions on building with basePath.
|
||||
#
|
||||
# Note: This configuration uses the published image (no build required).
|
||||
# Control Plane is served directly because Next.js basePath requires
|
||||
# build-time configuration. See README.md for the full stack option.
|
||||
|
||||
services:
|
||||
# Hindsight (API + Control Plane + embedded pg0)
|
||||
hindsight:
|
||||
image: ghcr.io/vectorize-io/hindsight:latest
|
||||
ports:
|
||||
- "9999:9999" # Control Plane (direct access, not proxied)
|
||||
environment:
|
||||
# API base path for reverse proxy
|
||||
HINDSIGHT_API_BASE_PATH: /hindsight
|
||||
|
||||
# LLM configuration
|
||||
# Using mock provider for testing (no API key needed)
|
||||
# For production, set OPENAI_API_KEY or ANTHROPIC_API_KEY and use a real provider
|
||||
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-mock}
|
||||
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-not-needed-for-mock}
|
||||
HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-mock-model}
|
||||
|
||||
# Production examples (uncomment and set appropriate API key):
|
||||
# HINDSIGHT_API_LLM_PROVIDER: openai
|
||||
# HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY}
|
||||
# HINDSIGHT_API_LLM_MODEL: gpt-4o-mini
|
||||
|
||||
# HINDSIGHT_API_LLM_PROVIDER: anthropic
|
||||
# HINDSIGHT_API_LLM_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
# HINDSIGHT_API_LLM_MODEL: claude-sonnet-4-20250514
|
||||
|
||||
# Server config
|
||||
HINDSIGHT_API_HOST: 0.0.0.0
|
||||
HINDSIGHT_API_PORT: 8888
|
||||
HINDSIGHT_API_LOG_LEVEL: info
|
||||
|
||||
# Control Plane config
|
||||
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
|
||||
volumes:
|
||||
# Persist embedded pg0 database
|
||||
- hindsight_data:/app/data
|
||||
# Note: Ports not exposed - access via Nginx at localhost:8080/hindsight/
|
||||
# To debug directly, uncomment these ports:
|
||||
# ports:
|
||||
# - "8888:8888" # API
|
||||
# - "9999:9999" # Control Plane
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/hindsight/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- hindsight
|
||||
|
||||
# Nginx reverse proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
hindsight:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- hindsight
|
||||
|
||||
volumes:
|
||||
hindsight_data:
|
||||
|
||||
networks:
|
||||
hindsight:
|
||||
@@ -0,0 +1,40 @@
|
||||
# Nginx configuration for API-only reverse proxy
|
||||
# Control Plane accessed directly (not through nginx)
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
# Upstream - Hindsight API
|
||||
upstream hindsight_api {
|
||||
server hindsight:8888;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# API endpoints - forward with /hindsight prefix
|
||||
location /hindsight/ {
|
||||
proxy_pass http://hindsight_api;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Redirect root to API docs
|
||||
location = / {
|
||||
return 301 /hindsight/docs;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,10 @@ RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' pa
|
||||
# Copy built SDK directly into node_modules (more reliable than npm link in Docker)
|
||||
COPY --from=sdk-builder /app/hindsight-clients/typescript ./node_modules/@vectorize-io/hindsight-client
|
||||
|
||||
# Accept base path as build argument for reverse proxy deployments
|
||||
# Usage: docker build --build-arg NEXT_PUBLIC_BASE_PATH=/hindsight ...
|
||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||
|
||||
# Build Control Plane - run next build first, then custom standalone copy
|
||||
# (The build:standalone script expects a specific path structure that differs in Docker)
|
||||
RUN npm exec -- next build
|
||||
|
||||
@@ -1491,6 +1491,9 @@ def create_app(
|
||||
logging.info("Memory system closed")
|
||||
|
||||
from hindsight_api import __version__
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
app = FastAPI(
|
||||
title="Hindsight HTTP API",
|
||||
@@ -1504,6 +1507,7 @@ def create_app(
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
},
|
||||
lifespan=lifespan,
|
||||
root_path=config.base_path,
|
||||
)
|
||||
|
||||
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
|
||||
|
||||
@@ -96,6 +96,7 @@ ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
|
||||
|
||||
ENV_HOST = "HINDSIGHT_API_HOST"
|
||||
ENV_PORT = "HINDSIGHT_API_PORT"
|
||||
ENV_BASE_PATH = "HINDSIGHT_API_BASE_PATH"
|
||||
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
||||
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
|
||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||
@@ -213,6 +214,7 @@ DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_BASE_PATH = "" # Empty string = root path
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
|
||||
DEFAULT_WORKERS = 1
|
||||
@@ -411,6 +413,7 @@ class HindsightConfig:
|
||||
# Server
|
||||
host: str
|
||||
port: int
|
||||
base_path: str
|
||||
log_level: str
|
||||
log_format: str
|
||||
mcp_enabled: bool
|
||||
@@ -609,6 +612,7 @@ class HindsightConfig:
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
base_path=os.getenv(ENV_BASE_PATH, DEFAULT_BASE_PATH),
|
||||
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
|
||||
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
|
||||
@@ -211,6 +211,7 @@ def main():
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Integration test for API base path support.
|
||||
|
||||
Tests that the API works correctly when deployed with a base path (e.g., /hindsight)
|
||||
for reverse proxy deployments.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import httpx
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client_with_base_path(memory):
|
||||
"""Create an async test client for the FastAPI app with a base path."""
|
||||
# Set base path in environment
|
||||
base_path = "/hindsight"
|
||||
os.environ["HINDSIGHT_API_BASE_PATH"] = base_path
|
||||
|
||||
# Clear config cache to force reload with new base_path
|
||||
clear_config_cache()
|
||||
|
||||
# Memory is already initialized by the conftest fixture (with migrations)
|
||||
app = create_app(memory, initialize_memory=False)
|
||||
|
||||
# Use base_url with base path
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url=f"http://test{base_path}"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
# Cleanup: unset base path
|
||||
os.environ.pop("HINDSIGHT_API_BASE_PATH", None)
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client_without_base_path(memory):
|
||||
"""Create an async test client for the FastAPI app without a base path (root)."""
|
||||
# Ensure no base path is set
|
||||
os.environ.pop("HINDSIGHT_API_BASE_PATH", None)
|
||||
clear_config_cache()
|
||||
|
||||
app = create_app(memory, initialize_memory=False)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_health_endpoint(api_client_with_base_path):
|
||||
"""Test that health endpoint works with base path."""
|
||||
# With base path set to /hindsight, health should be at /hindsight/health
|
||||
# But since our client base_url is already http://test/hindsight, we request /health
|
||||
response = await api_client_with_base_path.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
assert data["status"] in ["ok", "healthy"] # Accept both formats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_banks_endpoint(api_client_with_base_path):
|
||||
"""Test that banks endpoint works with base path."""
|
||||
response = await api_client_with_base_path.get("/v1/default/banks")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "banks" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_openapi_schema(api_client_with_base_path):
|
||||
"""Test that OpenAPI schema includes correct base path in servers."""
|
||||
response = await api_client_with_base_path.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
openapi_schema = response.json()
|
||||
|
||||
# Check that servers array includes base path
|
||||
assert "servers" in openapi_schema
|
||||
servers = openapi_schema["servers"]
|
||||
assert len(servers) > 0
|
||||
# FastAPI should set server URL to the root_path
|
||||
assert servers[0]["url"] == "/hindsight"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_docs_redirect(api_client_with_base_path):
|
||||
"""Test that /docs redirects correctly with base path."""
|
||||
# FastAPI docs endpoint should work
|
||||
response = await api_client_with_base_path.get("/docs", follow_redirects=False)
|
||||
# Should either return 200 (direct) or 307 (redirect to trailing slash)
|
||||
assert response.status_code in [200, 307]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_metrics(api_client_with_base_path):
|
||||
"""Test that metrics endpoint works with base path."""
|
||||
response = await api_client_with_base_path.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
# Metrics should be in Prometheus format
|
||||
assert "# HELP" in response.text or "# TYPE" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_full_workflow(api_client_with_base_path):
|
||||
"""
|
||||
Test a full retain/recall workflow with base path.
|
||||
|
||||
This ensures that all memory operations work correctly when the API
|
||||
is deployed with a base path.
|
||||
"""
|
||||
bank_id = "test_base_path_bank"
|
||||
|
||||
# 1. Create/get bank
|
||||
response = await api_client_with_base_path.get(f"/v1/default/banks/{bank_id}/profile")
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2. Store a memory
|
||||
response = await api_client_with_base_path.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "The API supports base path deployment for reverse proxy use cases.",
|
||||
"context": "testing base path feature"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["success"] is True
|
||||
|
||||
# 3. Recall the memory
|
||||
response = await api_client_with_base_path.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "base path support"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
recall_result = response.json()
|
||||
# API returns "results" not "memories"
|
||||
assert "results" in recall_result
|
||||
assert len(recall_result["results"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_base_path_still_works(api_client_without_base_path):
|
||||
"""
|
||||
Regression test: ensure default behavior (no base path) still works.
|
||||
|
||||
This test verifies that when HINDSIGHT_API_BASE_PATH is not set,
|
||||
the API works at the root path as before.
|
||||
"""
|
||||
# Health check at root
|
||||
response = await api_client_without_base_path.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Banks endpoint at root
|
||||
response = await api_client_without_base_path.get("/v1/default/banks")
|
||||
assert response.status_code == 200
|
||||
|
||||
# OpenAPI schema should have empty or "/" server path
|
||||
response = await api_client_without_base_path.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
openapi_schema = response.json()
|
||||
servers = openapi_schema.get("servers", [])
|
||||
if servers:
|
||||
# Server URL should be empty string (root) or "/"
|
||||
assert servers[0]["url"] in ["", "/"]
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="MCP endpoint routing with base path needs investigation")
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_path_mcp_endpoint(api_client_with_base_path):
|
||||
"""Test that MCP endpoint is accessible with base path."""
|
||||
bank_id = "test_mcp_bank"
|
||||
|
||||
# MCP endpoint should be mounted at /mcp/{bank_id}/
|
||||
# The MCP server uses a different protocol, so just check the root exists
|
||||
response = await api_client_with_base_path.get(f"/mcp/{bank_id}/")
|
||||
# MCP may return various status codes, but should not be 404 (not found)
|
||||
# Accept 405 (method not allowed), 400 (bad request), etc.
|
||||
assert response.status_code != 404, "MCP endpoint should exist"
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
basePath: basePath,
|
||||
assetPrefix: basePath,
|
||||
// Disable request logging in production
|
||||
logging: false,
|
||||
// Set the monorepo root explicitly to avoid detecting wrong lockfiles in parent directories
|
||||
|
||||
@@ -413,6 +413,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_HOST` | Bind address | `0.0.0.0` |
|
||||
| `HINDSIGHT_API_PORT` | Server port | `8888` |
|
||||
| `HINDSIGHT_API_BASE_PATH` | Base path for API when behind reverse proxy (e.g., `/hindsight`) | `""` (root) |
|
||||
| `HINDSIGHT_API_WORKERS` | Number of uvicorn worker processes | `1` |
|
||||
| `HINDSIGHT_API_LOG_LEVEL` | Log level: `debug`, `info`, `warning`, `error` | `info` |
|
||||
| `HINDSIGHT_API_LOG_FORMAT` | Log format: `text` or `json` (structured logging for cloud platforms) | `text` |
|
||||
@@ -633,12 +634,78 @@ The Control Plane is the web UI for managing memory banks.
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_CP_DATAPLANE_API_URL` | URL of the API service | `http://localhost:8888` |
|
||||
| `NEXT_PUBLIC_BASE_PATH` | Base path for Control Plane UI when behind reverse proxy (e.g., `/hindsight`) | `""` (root) |
|
||||
|
||||
```bash
|
||||
# Point Control Plane to a remote API service
|
||||
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
|
||||
```
|
||||
|
||||
### Reverse Proxy / Subpath Deployment
|
||||
|
||||
To deploy Hindsight under a subpath (e.g., `example.com/hindsight/`):
|
||||
|
||||
1. Set both environment variables to the same path:
|
||||
```bash
|
||||
HINDSIGHT_API_BASE_PATH=/hindsight
|
||||
NEXT_PUBLIC_BASE_PATH=/hindsight
|
||||
```
|
||||
|
||||
2. Configure your reverse proxy to:
|
||||
- Forward `/hindsight/*` requests to Hindsight
|
||||
- Preserve the full path in forwarded requests
|
||||
- Set appropriate proxy headers (X-Forwarded-Proto, X-Forwarded-For)
|
||||
|
||||
**Example: Nginx Configuration**
|
||||
|
||||
```nginx
|
||||
location /hindsight/ {
|
||||
proxy_pass http://localhost:8888/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Traefik Configuration**
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
hindsight:
|
||||
rule: "PathPrefix(`/hindsight`)"
|
||||
service: hindsight
|
||||
middlewares:
|
||||
- hindsight-stripprefix
|
||||
|
||||
middlewares:
|
||||
hindsight-stripprefix:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/hindsight"
|
||||
|
||||
services:
|
||||
hindsight:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://localhost:8888"
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- The base path must start with `/` and should NOT end with `/`
|
||||
- Both API and Control Plane should use the same base path
|
||||
- After setting environment variables, restart both services
|
||||
- OpenAPI docs will be available at `<base-path>/docs` (e.g., `/hindsight/docs`)
|
||||
|
||||
**Complete Examples:**
|
||||
|
||||
See `docker/compose-examples/` directory for:
|
||||
- Nginx configuration files (`simple.conf`, `api-and-control-plane.conf`)
|
||||
- Docker Compose setups (`docker-compose.yml`, `reverse-proxy-only.yml`)
|
||||
- Traefik and other reverse proxy examples
|
||||
- Full deployment documentation
|
||||
|
||||
---
|
||||
|
||||
## Example .env File
|
||||
|
||||
@@ -2,19 +2,60 @@
|
||||
|
||||
E2E and integration tests for Hindsight API that require a running server.
|
||||
|
||||
## Running Tests
|
||||
## Test Types
|
||||
|
||||
1. Start the API server:
|
||||
```bash
|
||||
./scripts/dev/start-api.sh
|
||||
```
|
||||
### 1. Tests with External Server
|
||||
Tests like `test_mcp_e2e.py` expect a server to already be running.
|
||||
|
||||
2. Run the tests:
|
||||
```bash
|
||||
cd hindsight-integration-tests
|
||||
HINDSIGHT_API_URL=http://localhost:8888 uv run pytest tests/ -v
|
||||
```
|
||||
**Running:**
|
||||
```bash
|
||||
# Start the API server
|
||||
./scripts/dev/start-api.sh
|
||||
|
||||
# Run tests
|
||||
cd hindsight-integration-tests
|
||||
HINDSIGHT_API_URL=http://localhost:8888 uv run pytest tests/test_mcp_e2e.py -v
|
||||
```
|
||||
|
||||
### 2. Self-Contained Tests
|
||||
Tests like `test_base_path_deployment.py` manage their own server lifecycle and use docker-compose.
|
||||
|
||||
**Running:**
|
||||
```bash
|
||||
cd hindsight-integration-tests
|
||||
|
||||
# Run with pytest
|
||||
uv run pytest tests/test_base_path_deployment.py -v
|
||||
|
||||
# Or run directly for nice output
|
||||
uv run python tests/test_base_path_deployment.py
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Docker and docker-compose installed (for reverse proxy test)
|
||||
- No nginx required on host!
|
||||
|
||||
**What it tests:**
|
||||
- ✅ API with base path (direct server)
|
||||
- ✅ Full reverse proxy via docker-compose + Nginx
|
||||
- ✅ Regression: API without base path
|
||||
- ✅ Full retain/recall workflow
|
||||
|
||||
These tests:
|
||||
- Start their own API servers on dedicated ports (18888-18891)
|
||||
- Use docker-compose to test actual deployment scenarios
|
||||
- Run in parallel with other tests (no port conflicts)
|
||||
- Clean up automatically
|
||||
|
||||
## Running All Tests
|
||||
|
||||
```bash
|
||||
cd hindsight-integration-tests
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
This runs both types. Self-contained tests won't conflict with the external server.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `HINDSIGHT_API_URL` - Base URL of the running Hindsight API (default: `http://localhost:8888`)
|
||||
- `HINDSIGHT_API_URL` - Base URL for external-server tests (default: `http://localhost:8888`)
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""
|
||||
Integration test for base path deployment using Docker Compose.
|
||||
|
||||
This test validates that Hindsight works correctly when deployed
|
||||
behind a reverse proxy with path-based routing using the actual
|
||||
Docker Compose examples from docker/compose-examples/.
|
||||
|
||||
Tests:
|
||||
1. API with base path (direct, no proxy)
|
||||
2. Full stack via docker-compose with Nginx reverse proxy
|
||||
3. Regression: API without base path still works
|
||||
|
||||
Requirements:
|
||||
- Docker and docker-compose installed
|
||||
- No nginx required on host!
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Paths
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||
API_PATH = REPO_ROOT / "hindsight-api"
|
||||
COMPOSE_EXAMPLES_PATH = REPO_ROOT / "docker" / "docker-compose" / "nginx"
|
||||
|
||||
# Add hindsight-api to path for direct API testing
|
||||
sys.path.insert(0, str(API_PATH))
|
||||
|
||||
|
||||
def run_command(cmd: list[str], cwd: str | Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def check_docker_available() -> bool:
|
||||
"""Check if Docker is available."""
|
||||
result = run_command(["docker", "info"])
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_docker_compose_command() -> list[str]:
|
||||
"""Get the docker-compose command (modern or legacy)."""
|
||||
import shutil
|
||||
# Try modern docker compose plugin first
|
||||
if shutil.which("docker"):
|
||||
result = run_command(["docker", "compose", "version"])
|
||||
if result.returncode == 0:
|
||||
return ["docker", "compose"]
|
||||
# Fall back to legacy docker-compose
|
||||
if shutil.which("docker-compose"):
|
||||
return ["docker-compose"]
|
||||
raise RuntimeError("docker-compose not available")
|
||||
|
||||
|
||||
def check_docker_compose_available() -> bool:
|
||||
"""Check if docker-compose is available."""
|
||||
try:
|
||||
get_docker_compose_command()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
class APIServer:
|
||||
"""Helper to manage API server lifecycle for direct testing."""
|
||||
|
||||
def __init__(self, base_path: str | None = None, port: int = 18888):
|
||||
self.base_path = base_path
|
||||
self.port = port
|
||||
self.process = None
|
||||
self.env = os.environ.copy()
|
||||
if base_path:
|
||||
self.env["HINDSIGHT_API_BASE_PATH"] = base_path
|
||||
|
||||
def start(self):
|
||||
"""Start the API server."""
|
||||
cmd = [
|
||||
"uv",
|
||||
"run",
|
||||
"--directory",
|
||||
str(API_PATH),
|
||||
"hindsight-api",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
str(self.port),
|
||||
]
|
||||
|
||||
log_file = f"/tmp/hindsight-api-{self.port}.log"
|
||||
self.log_file = open(log_file, "w")
|
||||
|
||||
self.process = subprocess.Popen(
|
||||
cmd, env=self.env, stdout=self.log_file, stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Wait for server to be ready
|
||||
base_url = f"http://localhost:{self.port}"
|
||||
if self.base_path:
|
||||
health_url = f"{base_url}{self.base_path}/health"
|
||||
else:
|
||||
health_url = f"{base_url}/health"
|
||||
|
||||
for _ in range(60): # 60 second timeout
|
||||
try:
|
||||
response = httpx.get(health_url, timeout=2.0)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except (httpx.ConnectError, httpx.ReadTimeout):
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
# Failed to start
|
||||
self.log_file.flush()
|
||||
with open(log_file) as f:
|
||||
print(f"API server failed to start. Logs:\n{f.read()}")
|
||||
raise RuntimeError(f"API server failed to start on port {self.port}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the API server."""
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self.process = None
|
||||
|
||||
if hasattr(self, "log_file") and self.log_file:
|
||||
self.log_file.close()
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
|
||||
|
||||
class DockerComposeStack:
|
||||
"""Helper to manage docker-compose stack lifecycle."""
|
||||
|
||||
def __init__(self, compose_file: Path, project_name: str = "hindsight-test"):
|
||||
self.compose_file = compose_file
|
||||
self.project_name = project_name
|
||||
self.compose_cmd = get_docker_compose_command()
|
||||
self.env = os.environ.copy()
|
||||
# Set required env vars for docker-compose
|
||||
self.env["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "test-key")
|
||||
self.env["HINDSIGHT_API_LLM_PROVIDER"] = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "mock")
|
||||
self.env["HINDSIGHT_API_LLM_MODEL"] = os.environ.get("HINDSIGHT_API_LLM_MODEL", "mock-model")
|
||||
|
||||
def start(self, timeout: int = 120):
|
||||
"""Start the docker-compose stack."""
|
||||
print(f"Starting docker-compose stack: {self.compose_file.name}")
|
||||
|
||||
# Pull images first (but don't fail if it doesn't work)
|
||||
run_command(
|
||||
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "pull"],
|
||||
cwd=self.compose_file.parent,
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
# Start services
|
||||
result = run_command(
|
||||
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "up", "-d", "--build"],
|
||||
cwd=self.compose_file.parent,
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to start docker-compose:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}")
|
||||
raise RuntimeError("Failed to start docker-compose stack")
|
||||
|
||||
# Wait for services to be healthy
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
result = run_command(
|
||||
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "ps", "--format", "json"],
|
||||
cwd=self.compose_file.parent,
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Give it a few more seconds to fully initialize
|
||||
time.sleep(5)
|
||||
return
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Timeout - show logs and fail
|
||||
self.show_logs()
|
||||
raise RuntimeError(f"Docker compose stack failed to start within {timeout}s")
|
||||
|
||||
def show_logs(self):
|
||||
"""Show docker-compose logs."""
|
||||
result = run_command(
|
||||
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "logs", "--tail=100"],
|
||||
cwd=self.compose_file.parent,
|
||||
env=self.env,
|
||||
)
|
||||
print(f"Docker compose logs:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop and remove the docker-compose stack."""
|
||||
print(f"Stopping docker-compose stack: {self.compose_file.name}")
|
||||
result = run_command(
|
||||
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "down", "-v"],
|
||||
cwd=self.compose_file.parent,
|
||||
env=self.env,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: Failed to stop docker-compose:\n{result.stderr}")
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.stop()
|
||||
|
||||
|
||||
def test_api_without_base_path():
|
||||
"""Regression test: API works at root path (default behavior)."""
|
||||
with APIServer(base_path=None, port=18888) as server:
|
||||
base_url = f"http://localhost:{server.port}"
|
||||
|
||||
# Health check
|
||||
response = httpx.get(f"{base_url}/health")
|
||||
assert response.status_code == 200
|
||||
assert "status" in response.json()
|
||||
|
||||
# API endpoints
|
||||
response = httpx.get(f"{base_url}/v1/default/banks")
|
||||
assert response.status_code == 200
|
||||
assert "banks" in response.json()
|
||||
|
||||
# OpenAPI docs
|
||||
response = httpx.get(f"{base_url}/docs")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_api_with_base_path_direct():
|
||||
"""Test API with base path configuration (direct, no proxy)."""
|
||||
base_path = "/hindsight"
|
||||
|
||||
with APIServer(base_path=base_path, port=18889) as server:
|
||||
base_url = f"http://localhost:{server.port}"
|
||||
|
||||
# Base path SHOULD work
|
||||
response = httpx.get(f"{base_url}{base_path}/health")
|
||||
assert response.status_code == 200
|
||||
assert "status" in response.json()
|
||||
|
||||
# API endpoints with base path
|
||||
response = httpx.get(f"{base_url}{base_path}/v1/default/banks")
|
||||
assert response.status_code == 200
|
||||
assert "banks" in response.json()
|
||||
|
||||
# OpenAPI docs with base path
|
||||
response = httpx.get(f"{base_url}{base_path}/docs")
|
||||
assert response.status_code == 200
|
||||
|
||||
# OpenAPI schema should have correct server URL
|
||||
response = httpx.get(f"{base_url}{base_path}/openapi.json")
|
||||
assert response.status_code == 200
|
||||
openapi = response.json()
|
||||
assert "servers" in openapi
|
||||
assert openapi["servers"][0]["url"] == base_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not check_docker_compose_available(),
|
||||
reason="docker-compose not available"
|
||||
)
|
||||
def test_reverse_proxy_simple_config():
|
||||
"""
|
||||
Test reverse proxy deployment using docker-compose with Nginx.
|
||||
|
||||
This creates a minimal test setup with:
|
||||
- API server running on HOST via uv (not Docker - faster, no image build needed!)
|
||||
- Nginx container that proxies to the host API
|
||||
|
||||
This tests the actual reverse proxy scenario without requiring the
|
||||
heavy Hindsight Docker image.
|
||||
"""
|
||||
base_path = "/hindsight"
|
||||
api_port = 18890
|
||||
|
||||
# Start API on host with base path
|
||||
with APIServer(base_path=base_path, port=api_port):
|
||||
# Create test docker-compose file (nginx only)
|
||||
test_compose = COMPOSE_EXAMPLES_PATH / "test-reverse-proxy.yml"
|
||||
|
||||
# Determine host address for nginx to reach host machine
|
||||
# host.docker.internal works on Docker Desktop (Mac/Windows)
|
||||
# On Linux, we use host network mode
|
||||
import platform
|
||||
if platform.system() == "Linux":
|
||||
network_mode = "host"
|
||||
api_host = "localhost"
|
||||
nginx_port = 18080 # With host mode, nginx must listen on 18080 directly
|
||||
port_mapping = "" # No port mapping with host mode
|
||||
else:
|
||||
network_mode = "bridge"
|
||||
api_host = "host.docker.internal"
|
||||
nginx_port = 80 # With bridge mode, nginx listens on 80 and is mapped
|
||||
port_mapping = """ ports:
|
||||
- "18080:80"
|
||||
"""
|
||||
|
||||
compose_content = f"""version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
{port_mapping} volumes:
|
||||
- ./test-nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
network_mode: {network_mode}
|
||||
"""
|
||||
|
||||
# Create test nginx config
|
||||
nginx_config = f"""events {{
|
||||
worker_connections 1024;
|
||||
}}
|
||||
|
||||
http {{
|
||||
server {{
|
||||
listen {nginx_port};
|
||||
server_name localhost;
|
||||
|
||||
location {base_path}/ {{
|
||||
proxy_pass http://{api_host}:{api_port};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
# Write test files
|
||||
test_compose.write_text(compose_content)
|
||||
test_nginx_conf = COMPOSE_EXAMPLES_PATH / "test-nginx.conf"
|
||||
test_nginx_conf.write_text(nginx_config)
|
||||
|
||||
try:
|
||||
# Start nginx via docker-compose
|
||||
with DockerComposeStack(test_compose, project_name="hindsight-base-path-test"):
|
||||
proxy_url = "http://localhost:18080"
|
||||
|
||||
# Give nginx a moment to start
|
||||
time.sleep(2)
|
||||
|
||||
# Test through nginx proxy
|
||||
response = httpx.get(f"{proxy_url}{base_path}/health", timeout=10.0)
|
||||
assert response.status_code == 200
|
||||
assert "status" in response.json()
|
||||
|
||||
# API endpoints through proxy
|
||||
response = httpx.get(f"{proxy_url}{base_path}/v1/default/banks", timeout=10.0)
|
||||
assert response.status_code == 200
|
||||
assert "banks" in response.json()
|
||||
|
||||
# OpenAPI docs through proxy
|
||||
response = httpx.get(f"{proxy_url}{base_path}/docs", timeout=10.0)
|
||||
assert response.status_code == 200
|
||||
|
||||
finally:
|
||||
# Cleanup test files
|
||||
test_compose.unlink(missing_ok=True)
|
||||
test_nginx_conf.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_workflow_with_base_path():
|
||||
"""Test full retain/recall workflow through base path."""
|
||||
base_path = "/hindsight"
|
||||
bank_id = "integration_test_bank"
|
||||
|
||||
with APIServer(base_path=base_path, port=18891) as server:
|
||||
base_url = f"http://localhost:{server.port}{base_path}"
|
||||
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client:
|
||||
# 1. Get bank profile (creates if needed)
|
||||
response = await client.get(f"/v1/default/banks/{bank_id}/profile")
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2. Store a memory
|
||||
response = await client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "Hindsight supports deployment under custom base paths for reverse proxy scenarios.",
|
||||
"context": "integration test"
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["success"] is True
|
||||
|
||||
# 3. Recall the memory
|
||||
response = await client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={"query": "base path deployment"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
recall_result = response.json()
|
||||
assert "results" in recall_result
|
||||
# Should find our memory
|
||||
assert len(recall_result["results"]) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Run tests directly with python."""
|
||||
import sys
|
||||
|
||||
print("=" * 70)
|
||||
print("Hindsight Base Path Integration Tests")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test 1: Without base path
|
||||
print("Test 1: API without base path (regression test)")
|
||||
print("-" * 70)
|
||||
try:
|
||||
test_api_without_base_path()
|
||||
print("✅ PASSED\n")
|
||||
except Exception as e:
|
||||
print(f"❌ FAILED: {e}\n")
|
||||
all_passed = False
|
||||
|
||||
# Test 2: With base path (direct)
|
||||
print("Test 2: API with base path (direct, no proxy)")
|
||||
print("-" * 70)
|
||||
try:
|
||||
test_api_with_base_path_direct()
|
||||
print("✅ PASSED\n")
|
||||
except Exception as e:
|
||||
print(f"❌ FAILED: {e}\n")
|
||||
all_passed = False
|
||||
|
||||
# Test 3: Docker compose reverse proxy
|
||||
print("Test 3: Reverse proxy via docker-compose")
|
||||
print("-" * 70)
|
||||
if check_docker_available() and check_docker_compose_available():
|
||||
try:
|
||||
test_reverse_proxy_simple_config()
|
||||
print("✅ PASSED\n")
|
||||
except Exception as e:
|
||||
print(f"❌ FAILED: {e}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
all_passed = False
|
||||
else:
|
||||
print("⚠️ SKIPPED: Docker or docker-compose not available\n")
|
||||
|
||||
# Test 4: Full workflow
|
||||
print("Test 4: Full retain/recall workflow with base path")
|
||||
print("-" * 70)
|
||||
try:
|
||||
asyncio.run(test_full_workflow_with_base_path())
|
||||
print("✅ PASSED\n")
|
||||
except Exception as e:
|
||||
print(f"❌ FAILED: {e}\n")
|
||||
all_passed = False
|
||||
|
||||
# Summary
|
||||
print("=" * 70)
|
||||
if all_passed:
|
||||
print("✅ All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("❌ Some tests failed")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user