jang0294's picture
Upload folder using huggingface_hub
4a8ceaa verified
Raw
History Blame Contribute Delete
18.7 kB
#!/usr/bin/env python3
"""
CI smoke — docs/HARDENING.md Phase 5 (login → 6 agent screens render).
Boots the real stack the way the demo runs it, then drives a browser
through the frontend AND exercises the hardened API paths end-to-end:
1. uvicorn api.server:app — fresh tmp sqlite DATABASE_URL,
BU_AUTH_DISABLED=1 (dev demo mode)
2. python -m http.server design/ — the zero-build frontend, exactly as
published (in-browser Babel; the CI
run IS the dev-mode regression test)
3. API smoke (urllib, no browser):
login → dry-run exam e2e (POST /atp/exams/run → poll /jobs/{id} →
award) → evidence + chain verify → license lifecycle
(issue → gated /experts chat → revoke → 403)
4. Playwright chromium:
login gate (admin/password) → all 6 agent-view tabs render
non-empty main content → zero console errors → Human view toggle
renders the trailhead.
Runnable locally and in CI (identical behavior):
pip install playwright && playwright install chromium
python3 scripts/ci_smoke.py
Exit 0 = green. Any assertion prints a ✗ line and exits 1.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DESIGN_DIR = ROOT / "design"
INDEX = "Brain%20University.html"
EXAM_CERT = "atp-l4-math" # seed cert with a commissioned item bank
LICENSED_AGENT = "gemma-math" # licensable marketplace agent (seed)
AGENT_TABS = ["university", "agents", "certs", "marketplace", "governance", "vault"]
#: Resource URLs that may 4xx without failing the smoke. Keep this SHORT and
#: explained:
#: favicon.ico — not shipped; browsers auto-request it
#: .compiled/manifest.json — the prod-build probe (HARDENING.md Phase 5);
#: 404 IS the zero-build dev mode working as
#: designed (page falls back to in-browser Babel)
RESOURCE_404_ALLOWLIST = ("favicon.ico", ".compiled/manifest.json")
_STEPS: list[str] = []
def ok(msg: str) -> None:
_STEPS.append(msg)
print(f" ✓ {msg}")
def die(msg: str) -> None:
print(f" ✗ {msg}", file=sys.stderr)
sys.exit(1)
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
# ── HTTP helpers (stdlib only — the API smoke needs no client deps) ────────
def request(method: str, url: str, body: dict | None = None,
headers: dict | None = None) -> tuple[int, dict]:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as e:
try:
payload = json.loads(e.read() or b"{}")
except json.JSONDecodeError:
payload = {}
return e.code, payload
def wait_for(url: str, timeout_s: float = 60.0, label: str = "") -> None:
deadline = time.monotonic() + timeout_s
last_err: Exception | None = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=3):
return
except urllib.error.HTTPError:
return # server is up; status is the endpoint's business
except Exception as e: # noqa: BLE001 — conn refused while booting
last_err = e
time.sleep(0.4)
die(f"{label or url} did not come up within {timeout_s:.0f}s ({last_err})")
# ── API smoke ────────────────────────────────────────────────────────────────
def poll_job(api: str, job_id: str, auth: dict,
timeout_s: float = 180.0) -> dict:
"""Poll /jobs/{id} until done/error; die on timeout. Returns job result."""
if not job_id:
die("poll_job called without a job id")
deadline = time.monotonic() + timeout_s
job: dict = {}
while time.monotonic() < deadline:
status, job = request("GET", f"{api}/jobs/{job_id}", headers=auth)
if status != 200:
die(f"GET /jobs/{job_id}{status} {job}")
if job.get("status") in ("done", "error"):
break
time.sleep(0.5)
if job.get("status") != "done":
die(f"job {job_id} did not finish: {job}")
return job.get("result") or {}
def api_smoke(api: str) -> None:
print("API smoke:")
status, body = request("POST", f"{api}/login",
{"username": "admin", "password": "password"})
if status != 200 or not body.get("token"):
die(f"POST /login admin/password → {status} {body}")
auth = {"Authorization": f"Bearer {body['token']}"}
ok("login admin/password → bearer token")
# dry-run certification exam, end to end through the job queue
status, body = request("POST", f"{api}/atp/exams/run",
{"certId": EXAM_CERT, "dryRun": True}, auth)
if status != 200 or not body.get("jobId"):
die(f"POST /atp/exams/run → {status} {body}")
job_id = body["jobId"]
deadline = time.monotonic() + 180
job: dict = {}
while time.monotonic() < deadline:
status, job = request("GET", f"{api}/jobs/{job_id}", headers=auth)
if status != 200:
die(f"GET /jobs/{job_id}{status} {job}")
if job.get("status") in ("done", "error"):
break
time.sleep(0.5)
if job.get("status") != "done":
die(f"exam job did not finish: {job}")
award = job.get("result") or {}
if award.get("certId") != EXAM_CERT or award.get("verdict") not in ("pass", "fail"):
die(f"exam award malformed: {award}")
evidence_ids = award.get("evidenceIds") or []
if not evidence_ids:
die(f"exam award carries no evidence: {award}")
ok(f"dry-run exam {EXAM_CERT} → verdict={award['verdict']} "
f"score={award.get('score')} ({len(evidence_ids)} evidence rows)")
status, body = request(
"GET", f"{api}/atp/evidence/{evidence_ids[0]}/verify", headers=auth)
if status != 200 or not body.get("ok") or not body.get("sigValid"):
die(f"evidence verify → {status} {body}")
status, body = request("GET", f"{api}/atp/chain/verify", headers=auth)
if status != 200 or not body.get("ok") or body.get("length", 0) < len(evidence_ids):
die(f"chain verify → {status} {body}")
ok(f"evidence signature + org chain verify (length={body.get('length')})")
# license lifecycle: issue → gated chat → revoke → 403
status, body = request("POST", f"{api}/billing/licenses",
{"agentId": LICENSED_AGENT}, auth)
if status != 200 or not body.get("licenseKey") or not (body.get("license") or {}).get("id"):
die(f"POST /billing/licenses → {status} {body}")
license_id, key = body["license"]["id"], body["licenseKey"]
ok(f"license issued for {LICENSED_AGENT} (id={license_id})")
chat_body = {"messages": [{"role": "user", "content": "What is 2 + 2?"}]}
chat_url = f"{api}/experts/{LICENSED_AGENT}/chat"
status, body = request("POST", chat_url, chat_body,
{"X-ATP-License-Key": key})
if status != 200 or not body.get("reply"):
die(f"licensed chat → {status} {body}")
ok("licensed /experts chat replied (usage metered)")
status, body = request("POST", chat_url, chat_body,
{"X-ATP-License-Key": "atpk_forged.deadbeef"})
if status != 401:
die(f"forged key must 401, got {status} {body}")
status, body = request("POST", f"{api}/billing/licenses/{license_id}/revoke",
{"reason": "ci smoke"}, auth)
if status != 200:
die(f"revoke → {status} {body}")
status, body = request("POST", chat_url, chat_body,
{"X-ATP-License-Key": key})
if status != 403:
die(f"revoked key must 403 on the very next call, got {status} {body}")
ok("revoked license → 403 on the next chat call (forged key → 401)")
# ── Phase 6: report-card PDF + knowledge ingestion → SME sign-off → exam ──
pdf_req = urllib.request.Request(f"{api}/atp/agents/atlas-ops/report.pdf",
headers=auth)
with urllib.request.urlopen(pdf_req, timeout=60) as resp:
pdf_bytes = resp.read()
if resp.status != 200 or not pdf_bytes.startswith(b"%PDF"):
die(f"report.pdf → {resp.status}, magic {pdf_bytes[:8]!r}")
ok(f"report-card PDF renders ({len(pdf_bytes)} bytes)")
# generate_l5_draft needs >= 2 docs (blueprint sections = doc titles)
boundary = "ciSmokeBoundary"
docs_md = {
# >= 2 headings per doc: the generator yields ~2 items per heading
# and requires >= 8 items overall.
"runbook.md": (
"# Incident Runbook\n\n"
"## Acknowledge\n\nAcknowledge the page within five minutes "
"and open an incident channel named for the alert.\n\n"
"## Escalate\n\nEscalate to the on-call lead when customer "
"impact lasts longer than fifteen minutes.\n"),
"rollback.md": (
"# Rollback Policy\n\n"
"## Snapshot first\n\nAlways snapshot the database before any "
"rollback and record the snapshot id in the channel.\n\n"
"## Notify\n\nNotify the on-call lead and the release owner "
"before starting the rollback.\n"),
}
doc_ids = []
for fname, doc_md in docs_md.items():
mp = (f"--{boundary}\r\nContent-Disposition: form-data; "
f"name=\"file\"; filename=\"{fname}\"\r\n"
f"Content-Type: text/markdown\r\n\r\n"
f"{doc_md}\r\n--{boundary}--\r\n").encode()
up_req = urllib.request.Request(f"{api}/knowledge/docs", data=mp,
method="POST", headers=auth)
up_req.add_header("Content-Type",
f"multipart/form-data; boundary={boundary}")
with urllib.request.urlopen(up_req, timeout=30) as resp:
doc = json.loads(resp.read())
doc_ids.append(doc.get("id") or die(f"knowledge upload returned {doc}"))
status, draft = request("POST", f"{api}/knowledge/drafts",
{"docIds": doc_ids, "title": "CI L5 draft"},
headers=auth)
if status != 200 or len(draft.get("items", [])) < 8:
die(f"draft → {status}, items {len(draft.get('items', []))}")
status, appr = request("POST",
f"{api}/knowledge/drafts/{draft['id']}/approve",
{"note": "ci smoke sign-off"}, headers=auth)
if status != 200 or not appr.get("evidenceId"):
die(f"approve → {status} {appr}")
status, job = request("POST",
f"{api}/knowledge/drafts/{draft['id']}/exam",
{"dryRun": True}, headers=auth)
if status != 200:
die(f"draft exam → {status} {job}")
award = poll_job(api, job.get("jobId") or job.get("job_id"), auth)
if not award.get("verdict"):
die(f"draft-exam award missing verdict: {award}")
status, chain = request("GET", f"{api}/atp/chain/verify", headers=auth)
if status != 200 or not chain.get("ok"):
die(f"chain broken after knowledge flow: {chain}")
ok("knowledge e2e: upload → draft → SME sign-off → dry-run exam → chain ok")
# ── Browser smoke ────────────────────────────────────────────────────────────
def browser_smoke(api: str, web: str) -> None:
from playwright.sync_api import sync_playwright
from playwright.sync_api import TimeoutError as PWTimeoutError
print("Browser smoke (playwright chromium):")
console_errors: list[str] = []
page_errors: list[str] = []
bad_responses: list[str] = []
def note_response(resp) -> None:
if resp.status >= 400 and not any(
allowed in resp.url for allowed in RESOURCE_404_ALLOWLIST):
bad_responses.append(f"HTTP {resp.status} {resp.url}")
with sync_playwright() as pw:
browser = pw.chromium.launch()
context = browser.new_context()
page = context.new_page()
page.set_default_timeout(120_000)
# The deployed design/config.js points at the hosted backend; serve
# the CI backend instead without touching the working tree.
page.route("**/config.js", lambda route: route.fulfill(
status=200, content_type="application/javascript",
body=f'window.BU_API_BASE = "{api}";\n'))
# 'Failed to load resource' console lines carry no resource URL, so
# judge those via the response listener (which has status + URL and
# can honor RESOURCE_404_ALLOWLIST); keep every other console error.
page.on("console", lambda m: console_errors.append(m.text)
if m.type == "error"
and not m.text.startswith("Failed to load resource") else None)
page.on("pageerror", lambda e: page_errors.append(str(e)))
page.on("response", note_response)
page.goto(f"{web}/{INDEX}")
# Login gate (BU_AUTH_DISABLED=1 dev creds — api/auth.py)
page.fill("#bu-gate-u", "admin")
page.fill("#bu-gate-p", "password")
page.click("#bu-gate-form button[type=submit]")
page.wait_for_selector("#bu-gate", state="hidden")
ok("login gate accepted admin/password")
# App shell up = in-browser Babel compiled every .jsx (zero-build
# dev mode contract). Fresh profile defaults to the agent view.
# One reload retry: the pinned React/Babel/three CDN fetches are the
# only non-local resources and occasionally stall on a cold edge.
try:
page.wait_for_selector('nav.nav button[data-nav="university"]')
except PWTimeoutError:
print(" … app shell slow (CDN?) — reloading once", file=sys.stderr)
page.reload()
page.wait_for_selector('nav.nav button[data-nav="university"]')
for tab in AGENT_TABS:
page.click(f'button[data-nav="{tab}"]')
page.wait_for_function(
"""(tab) => {
const btn = document.querySelector(`button[data-nav="${tab}"]`);
const main = document.querySelector('main');
return btn && btn.classList.contains('active') &&
main && main.innerText.trim().length > 80;
}""",
arg=tab)
chars = page.evaluate(
"document.querySelector('main').innerText.trim().length")
ok(f"agent tab '{tab}' rendered non-empty main ({chars} chars)")
# Human ◦ Agent toggle → trailhead
page.click('.view-toggle button:has-text("Human")')
page.wait_for_function(
"""() => {
const btn = document.querySelector('button[data-nav="trailhead"]');
const main = document.querySelector('main');
return btn && btn.classList.contains('active') &&
main && main.innerText.trim().length > 40;
}""")
ok("human view toggle → trailhead rendered")
page.wait_for_timeout(1500) # let late async fetches surface errors
browser.close()
real_errors = console_errors + page_errors + bad_responses
if real_errors:
for e in real_errors:
print(f" console/network error: {e}", file=sys.stderr)
die(f"{len(real_errors)} console/network error(s) during the browser smoke")
ok("zero console errors across login + 7 screens")
def main() -> int:
api_port, web_port = free_port(), free_port()
api = f"http://127.0.0.1:{api_port}"
web = f"http://127.0.0.1:{web_port}"
procs: list[subprocess.Popen] = []
with tempfile.TemporaryDirectory(prefix="bu-ci-smoke-") as tmp:
env = os.environ.copy()
env.update({
"BU_AUTH_DISABLED": "1", # demo mode contract
"DATABASE_URL": f"sqlite:///{tmp}/ci.db", # fresh throwaway DB
"PYTHONUNBUFFERED": "1",
# This is a functional smoke, not a rate-limit test: the exam
# poll loop + browser traffic share one (IP, subject) bucket and
# can exceed api/ops.py's default 120/60s. Explicitly off, per
# its own env-tuning contract (BU_RATE_LIMIT=0 disables).
"BU_RATE_LIMIT": "0",
# Phase 6 surfaces: T2 encryption + evidence signing need keys
# even in demo mode (knowledge routes 503 without them).
"DATA_ENCRYPTION_KEY": "ci-smoke-data-key-0123456789abcdef",
"EVIDENCE_SIGNING_KEY": "ci-smoke-evidence-key-0123456789ab",
})
try:
procs.append(subprocess.Popen(
[sys.executable, "-m", "uvicorn", "api.server:app",
"--host", "127.0.0.1", "--port", str(api_port)],
cwd=ROOT, env=env))
procs.append(subprocess.Popen(
[sys.executable, "-m", "http.server", str(web_port),
"--bind", "127.0.0.1", "--directory", str(DESIGN_DIR)],
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
wait_for(f"{api}/health", 90, "uvicorn api.server:app")
wait_for(f"{web}/{INDEX}", 30, "http.server design/")
print(f"servers up: api={api} web={web}")
api_smoke(api)
browser_smoke(api, web)
finally:
for p in procs:
p.terminate()
for p in procs:
try:
p.wait(timeout=10)
except subprocess.TimeoutExpired:
p.kill()
print(f"ci_smoke: OK — {len(_STEPS)} checks green.")
return 0
if __name__ == "__main__":
sys.exit(main())