Files
PraisonAIUI/tests/test_examples_parity.py
a433c483c1 fix: document bootstrap entrypoints in examples (fixes #71) (#96)
* feat: standardize bootstrap entrypoint to 'aiui run app.py' across examples

Standardizes platform bootstrap patterns across examples per issue #71:

- Update examples README table to use 'aiui run app.py' consistently
- Update docstrings in examples 10, 12, 19-21 to show 'aiui run' instead of 'python app.py'
- Remove fragmented bootstrap approaches (uvicorn.run, asyncio patterns)
- Enforce single bootstrap SSOT as designed in CLI

Co-authored-by: Mervin Praison <[email protected]>

* feat: consolidate bootstrap entrypoints - auto-detect gateway apps (fixes #71)

- Add bootstrap detection in CLI run command
- Auto-detect gateway pattern (async main + AIUIGateway imports)
- Auto-switch to praisonai backend for gateway apps
- Delegate to user-defined main() when present
- Remove sys.path hacks from example 14
- Update examples README to show unified aiui run entrypoint
- Add BOOTSTRAP.md documentation for migration patterns

Co-authored-by: Mervin Praison <[email protected]>

* fix: resolve critical bootstrap entrypoint issues

- Fix AST parsing bug in gateway auto-detection (cli.py:1419)
- Revert background bots (email-bot, agentmail-bot) to python app.py
- Revert custom UI apps (agent-dashboard) to python app.py
- Update README.md to reflect correct run commands
- Fix import formatting and remove unused imports

These changes ensure that:
1. Gateway auto-detection works correctly by properly checking ast.alias.name
2. Background bots execute their __main__ blocks and don't start unnecessary web servers
3. Custom UI apps register their custom routes in __main__ blocks
4. Documentation matches actual working run commands

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-15 16:36:35 +01:00

418 lines
14 KiB
Python

#!/usr/bin/env python3
"""Comprehensive CLI + API parity test for all PraisonAIUI examples.
For each example:
1. Starts the server (aiui run) on a unique port
2. Hits every API endpoint with curl
3. Runs corresponding CLI commands
4. Reports pass/fail
Usage:
PYTHONPATH=src python3 tests/test_examples_parity.py
"""
import json
import os
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
EXAMPLES = REPO / "examples"
SRC = REPO / "src"
BASE_PORT = 8100 # start port, incremented per example
# Colours
GREEN = "\033[92m"
RED = "\033[91m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"
def api_get(port: int, path: str, timeout: float = 5.0):
"""GET an API endpoint, return (status, json_body|None)."""
try:
url = f"http://127.0.0.1:{port}{path}"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
try:
return resp.status, json.loads(body)
except json.JSONDecodeError:
return resp.status, body.decode()[:200]
except urllib.error.HTTPError as e:
return e.code, None
except Exception as e:
return 0, str(e)
def api_post(port: int, path: str, body: dict = None, timeout: float = 5.0):
"""POST to an API endpoint."""
try:
url = f"http://127.0.0.1:{port}{path}"
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
rbody = resp.read()
try:
return resp.status, json.loads(rbody)
except json.JSONDecodeError:
return resp.status, rbody.decode()[:200]
except urllib.error.HTTPError as e:
return e.code, None
except Exception as e:
return 0, str(e)
def wait_for_server(port: int, max_wait: float = 15.0) -> bool:
"""Wait until server responds on /health."""
start = time.time()
while time.time() - start < max_wait:
try:
status, _ = api_get(port, "/health", timeout=2.0)
if status == 200:
return True
except Exception:
pass
time.sleep(0.5)
return False
def start_server(app_path: str, port: int, is_yaml: bool = False) -> subprocess.Popen:
"""Start aiui server in background."""
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC)
env["BROWSER"] = "echo" # suppress auto-open
cmd = [
sys.executable, "-m", "praisonaiui.cli",
"run", app_path,
"--port", str(port),
]
proc = subprocess.Popen(
cmd,
cwd=str(REPO),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return proc
def stop_server(proc: subprocess.Popen):
"""Stop server process."""
try:
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
proc.wait(timeout=3)
except Exception:
pass
# ---------------------------------------------------------------------------
# API Test Suite — every endpoint the server exposes
# ---------------------------------------------------------------------------
def test_core_api(port: int) -> list:
"""Test core server API endpoints. Returns list of (name, passed, detail)."""
results = []
# Health
status, data = api_get(port, "/health")
results.append(("GET /health", status == 200, f"status={status}"))
# Agents
status, data = api_get(port, "/agents")
results.append(("GET /agents", status == 200, f"status={status}"))
# Starters
status, data = api_get(port, "/starters")
results.append(("GET /starters", status == 200, f"status={status}"))
# Profiles
status, data = api_get(port, "/profiles")
results.append(("GET /profiles", status == 200, f"status={status}"))
# Sessions — list
status, data = api_get(port, "/sessions")
results.append(("GET /sessions", status == 200, f"status={status}"))
# Sessions — create
status, data = api_post(port, "/sessions")
results.append(("POST /sessions", status == 200, f"status={status}"))
# Welcome trigger
status, data = api_post(port, "/welcome")
ok = status in (200, 204)
results.append(("POST /welcome", ok, f"status={status}"))
# Dashboard API
status, data = api_get(port, "/api/overview")
results.append(("GET /api/overview", status == 200, f"status={status}"))
status, data = api_get(port, "/api/config")
results.append(("GET /api/config", status == 200, f"status={status}"))
status, data = api_get(port, "/api/logs")
results.append(("GET /api/logs", status == 200, f"status={status}"))
status, data = api_get(port, "/api/usage")
results.append(("GET /api/usage", status == 200, f"status={status}"))
status, data = api_get(port, "/api/debug")
results.append(("GET /api/debug", status == 200, f"status={status}"))
status, data = api_get(port, "/api/provider")
results.append(("GET /api/provider", status == 200, f"status={status}"))
# Pages
status, data = api_get(port, "/api/pages")
results.append(("GET /api/pages", status == 200, f"status={status}"))
# A2UI surfaces
status, data = api_get(port, "/api/surfaces")
results.append(("GET /api/surfaces", status == 200, f"status={status}"))
status, data = api_get(port, "/api/surfaces/main")
ok = status == 200 and isinstance(data, dict) and "messages" in data
results.append(("GET /api/surfaces/main", ok, f"status={status}"))
# Features
status, data = api_get(port, "/api/features")
results.append(("GET /api/features", status == 200, f"status={status}"))
return results
def test_feature_apis(port: int) -> list:
"""Test feature-specific API endpoints (if features are registered)."""
results = []
status, data = api_get(port, "/api/features")
if status != 200 or not data:
return results
features = data.get("features", []) if isinstance(data, dict) else []
if not features:
return results
# Feature routes — try common ones
feature_endpoints = [
("GET /api/approvals", "/api/approvals"),
("GET /api/schedules", "/api/schedules"),
("GET /api/memory", "/api/memory"),
("GET /api/skills", "/api/skills"),
("GET /api/hooks", "/api/hooks"),
("GET /api/workflows", "/api/workflows"),
("GET /api/config/runtime", "/api/config/runtime"),
]
for name, path in feature_endpoints:
status, _ = api_get(port, path)
ok = status in (200, 404) # 404 = endpoint not registered; 200 = registered
results.append((name, ok, f"status={status}"))
return results
# ---------------------------------------------------------------------------
# CLI Test Suite — test CLI commands against running server
# ---------------------------------------------------------------------------
def run_cli(args: list, port: int) -> tuple:
"""Run a CLI command and return (returncode, stdout)."""
env = os.environ.copy()
env["PYTHONPATH"] = str(SRC)
cmd = [sys.executable, "-m", "praisonaiui.cli"] + args + [
"--server", f"http://127.0.0.1:{port}"
]
try:
result = subprocess.run(
cmd, cwd=str(REPO), env=env,
capture_output=True, text=True, timeout=10,
)
return result.returncode, result.stdout
except subprocess.TimeoutExpired:
return -1, "TIMEOUT"
except Exception as e:
return -1, str(e)
def test_cli_commands(port: int) -> list:
"""Test CLI commands that correspond to API endpoints."""
results = []
# health
rc, out = run_cli(["health-check"], port)
results.append(("CLI health-check", rc == 0, out.strip()[:80]))
# features list
rc, out = run_cli(["features", "list"], port)
results.append(("CLI features list", rc == 0, out.strip()[:80]))
# features status
rc, out = run_cli(["features", "status"], port)
results.append(("CLI features status", rc == 0, out.strip()[:80]))
# session-ext state
rc, out = run_cli(["session-ext", "state", "default"], port)
results.append(("CLI session-ext state", rc == 0, out.strip()[:80]))
# session-ext labels
rc, out = run_cli(["session-ext", "labels", "default"], port)
results.append(("CLI session-ext labels", rc == 0, out.strip()[:80]))
# session-ext usage
rc, out = run_cli(["session-ext", "usage", "default"], port)
results.append(("CLI session-ext usage", rc == 0, out.strip()[:80]))
return results
def test_feature_cli(port: int) -> list:
"""Test feature-specific CLI commands."""
results = []
feature_cmds = [
("CLI approval list", ["approval", "list"]),
("CLI approval pending", ["approval", "pending"]),
("CLI schedule list", ["schedule", "list"]),
("CLI schedule status", ["schedule", "status"]),
("CLI memory list", ["memory", "list"]),
("CLI memory status", ["memory", "status"]),
("CLI skills list", ["skills", "list"]),
("CLI skills status", ["skills", "status"]),
("CLI hooks list", ["hooks", "list"]),
("CLI workflows list", ["workflows", "list"]),
("CLI workflows status", ["workflows", "status"]),
("CLI config list", ["config", "list"]),
("CLI surface list", ["surface", "list"]),
("CLI surface get main", ["surface", "get", "main"]),
("CLI surface status", ["surface", "status"]),
]
for name, args in feature_cmds:
rc, out = run_cli(args, port)
# These may fail if features not registered — that's OK
ok = rc == 0 or "Error" in out # at least the CLI command exists
results.append((name, ok, out.strip()[:80]))
return results
# ---------------------------------------------------------------------------
# Main test runner
# ---------------------------------------------------------------------------
def test_example(name: str, app_file: str, port: int, is_yaml: bool = False) -> dict:
"""Test a single example — start server, run API + CLI tests, stop server."""
print(f"\n{'='*60}")
print(f"{BOLD}Testing: {name}{RESET}")
print(f" File: {app_file}")
print(f" Port: {port}")
print(f"{'='*60}")
proc = start_server(app_file, port, is_yaml)
try:
if not wait_for_server(port):
# Read stderr for error info
try:
_, stderr = proc.communicate(timeout=2)
err_msg = stderr.decode()[-300:] if stderr else "no output"
except Exception:
err_msg = "timeout"
print(f" {RED}✗ Server failed to start{RESET}")
print(f" {DIM}{err_msg}{RESET}")
return {"name": name, "passed": 0, "failed": 1, "skipped": 0,
"results": [("Server Start", False, err_msg[:100])]}
print(f" {GREEN}✓ Server started{RESET}")
all_results = []
# Core API tests
api_results = test_core_api(port)
all_results.extend(api_results)
# Feature API tests
feature_api = test_feature_apis(port)
all_results.extend(feature_api)
# Core CLI tests
cli_results = test_cli_commands(port)
all_results.extend(cli_results)
# Feature CLI tests
feature_cli = test_feature_cli(port)
all_results.extend(feature_cli)
passed = sum(1 for _, ok, _ in all_results if ok)
failed = sum(1 for _, ok, _ in all_results if not ok)
for name_t, ok, detail in all_results:
icon = f"{GREEN}✓{RESET}" if ok else f"{RED}✗{RESET}"
print(f" {icon} {name_t} {DIM}{detail}{RESET}")
return {"name": name, "passed": passed, "failed": failed,
"skipped": 0, "results": all_results}
finally:
stop_server(proc)
def main():
print(f"\n{BOLD}PraisonAIUI — Comprehensive Example Testing{RESET}")
print("Testing API endpoints + CLI parity for all examples\n")
# Discover examples
python_examples = sorted((EXAMPLES / "python").iterdir())
yaml_chat_examples = sorted([
d for d in (EXAMPLES / "yaml").iterdir()
if d.is_dir() and (d / "chat.yaml").exists()
])
# Build test list
test_items = []
port = BASE_PORT
for d in python_examples:
if d.is_dir() and (d / "app.py").exists():
test_items.append((d.name, str(d / "app.py"), port, False))
port += 1
for d in yaml_chat_examples:
test_items.append((f"yaml/{d.name}", str(d / "chat.yaml"), port, True))
port += 1
print(f"Found {len(test_items)} testable examples\n")
# Run tests
summaries = []
total_passed = 0
total_failed = 0
for name, app_file, test_port, is_yaml in test_items:
result = test_example(name, app_file, test_port, is_yaml)
summaries.append(result)
total_passed += result["passed"]
total_failed += result["failed"]
# Summary
print(f"\n\n{'='*60}")
print(f"{BOLD}FINAL SUMMARY{RESET}")
print(f"{'='*60}")
for s in summaries:
icon = f"{GREEN}✓{RESET}" if s["failed"] == 0 else f"{RED}✗{RESET}"
print(f" {icon} {s['name']:40s} {s['passed']} passed, {s['failed']} failed")
print(f"\n{'─'*60}")
print(f" Total: {total_passed} passed, {total_failed} failed")
print(f"{'─'*60}\n")
sys.exit(0 if total_failed == 0 else 1)
if __name__ == "__main__":
main()